mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
4065 lines
176 KiB
TypeScript
4065 lines
176 KiB
TypeScript
/**
|
||
* @fileoverview Session management routes.
|
||
* Covers session CRUD, input/output, terminal buffer, quick-start, quick-run,
|
||
* auto-clear, auto-compact, image watcher, flicker filter, and logout.
|
||
*/
|
||
|
||
import { FastifyInstance, type FastifyReply } from 'fastify';
|
||
import { z } from 'zod';
|
||
import { join, dirname, extname, basename } from 'node:path';
|
||
import { homedir } from 'node:os';
|
||
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
|
||
import { execFile } from 'node:child_process';
|
||
import fs from 'node:fs/promises';
|
||
import { randomBytes } from 'node:crypto';
|
||
import {
|
||
ApiErrorCode,
|
||
createErrorResponse,
|
||
getErrorMessage,
|
||
type ApiResponse,
|
||
type SessionColor,
|
||
type SessionStatus,
|
||
type CodexConfig,
|
||
type GeminiConfig,
|
||
type AntigravityConfig,
|
||
} from '../../types.js';
|
||
import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
|
||
import { SseEvent } from '../sse-events.js';
|
||
import {
|
||
CreateSessionSchema,
|
||
SessionNameSchema,
|
||
SessionColorSchema,
|
||
RunPromptSchema,
|
||
SessionInputWithLimitSchema,
|
||
ResizeSchema,
|
||
AutoClearSchema,
|
||
AutoCompactSchema,
|
||
AutoResumeSchema,
|
||
PinSessionSchema,
|
||
ImageWatcherSchema,
|
||
FlickerFilterSchema,
|
||
QuickRunSchema,
|
||
QuickStartSchema,
|
||
InteractiveStartSchema,
|
||
SessionOrderUpdateSchema,
|
||
SessionWaitQuerySchema,
|
||
SessionWaitOutputQuerySchema,
|
||
} from '../schemas.js';
|
||
import { mergeSessionOrder } from '../../session-order.js';
|
||
import {
|
||
sessionWaits,
|
||
resolveWaitSignals,
|
||
signalForStatus,
|
||
WaitCapacityError,
|
||
type WaitSignal,
|
||
type SignalWaitResult,
|
||
} from '../session-wait-registry.js';
|
||
import { clampWaitMs, MAX_BUFFER_SCAN_BYTES } from '../../config/agent-wait.js';
|
||
import {
|
||
autoConfigureRalph,
|
||
canAccessOwned,
|
||
CASES_DIR,
|
||
findSessionOrFail,
|
||
getAuthUser,
|
||
isAdmin,
|
||
isWorkingDirAllowed,
|
||
ownerFor,
|
||
parseBody,
|
||
persistAndBroadcastSession,
|
||
resolveCasesDir,
|
||
resolveParentSessionId,
|
||
sessionCapacityMessage,
|
||
SETTINGS_PATH,
|
||
validatePathWithinBase,
|
||
} from '../route-helpers.js';
|
||
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
|
||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||
import {
|
||
writeHooksConfig,
|
||
updateCaseModel,
|
||
stripCaseEnvKeys,
|
||
applyStatusLineConfig,
|
||
applyAgentSkill,
|
||
refreshStaleCodemanHooks,
|
||
} from '../../hooks-config.js';
|
||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||
import { imageWatcher } from '../../image-watcher.js';
|
||
import { convertHeicToJpeg } from '../heic-jpeg-converter.js';
|
||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||
import {
|
||
mergeUnifiedSessions,
|
||
filterAndPaginate,
|
||
type LiveSessionInput,
|
||
type PersistedSessionInput,
|
||
type LifecycleInput,
|
||
type HistoryInput,
|
||
type MuxStatInput,
|
||
type UnifiedSessionItem,
|
||
} from '../../services/unified-session-service.js';
|
||
import {
|
||
buildHistorySessionIndexItems,
|
||
setHistoryIndexRefresher,
|
||
setHistorySessionIndex,
|
||
} from '../session-history-index.js';
|
||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
||
import { RunSummaryTracker } from '../../run-summary.js';
|
||
|
||
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
||
import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
|
||
import { dataPath, getDataDir } from '../../config/instance.js';
|
||
import {
|
||
checkRemoteTmuxAvailable,
|
||
readRemoteCases,
|
||
readRemoteHosts,
|
||
toAttachedSessionRemote,
|
||
toSessionRemote,
|
||
} from '../../remote-hosts.js';
|
||
import {
|
||
checkDockerAvailable,
|
||
checkDockerConfigDrift,
|
||
checkDockerTmuxAvailable,
|
||
ensureAgentBaseImage,
|
||
DEFAULT_AGENT_IMAGE,
|
||
persistDockerCaseClaudeSessionId,
|
||
readDockerCases,
|
||
readDockerHosts,
|
||
toSessionDocker,
|
||
} from '../../docker-hosts.js';
|
||
import { LRUMap } from '../../utils/lru-map.js';
|
||
|
||
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
|
||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||
const CODEMAN_CONFIG_DIR = getDataDir();
|
||
|
||
// Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation)
|
||
// eslint-disable-next-line no-control-regex
|
||
const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/;
|
||
// eslint-disable-next-line no-control-regex
|
||
const CTRL_L_PATTERN = /\x0c/g;
|
||
const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
|
||
|
||
/**
|
||
* Match xterm alternate-screen mode toggles + the standalone scrollback-erase.
|
||
*
|
||
* - DECSET/DECRST 47, 1047, 1049 = enter/exit alternate screen buffer
|
||
* (1049 also saves cursor and clears the alt buffer).
|
||
* - CSI 3 J = erase saved lines (scrollback).
|
||
*
|
||
* Codex AND Claude Code emit `\x1b[?1049h` and clear-scrollback sequences (the
|
||
* latter intermittently, e.g. full-screen pickers/dialogs). xterm.js obeys them
|
||
* by switching to the alt buffer (no native scrollback) and wiping saved lines,
|
||
* so the user's conversation history disappears on every tab switch / pane
|
||
* refresh (and scroll-up breaks live). Stripping these from the replayed byte
|
||
* stream keeps everything in the main buffer with scrollback intact. Mirrors the
|
||
* live-stream strip in Session._handleTerminalOutput (isAltScreenStripMode).
|
||
*/
|
||
// eslint-disable-next-line no-control-regex
|
||
const ALT_SCREEN_TOGGLE_PATTERN = /\x1b\[\?(?:47|1047|1049)[hl]/g;
|
||
// eslint-disable-next-line no-control-regex
|
||
const ERASE_SCROLLBACK_PATTERN = /\x1b\[3J/g;
|
||
// Mouse-tracking enables (X10/button/any-event/UTF-8/SGR/alt-scroll) — once on,
|
||
// xterm.js forwards wheel events to the app instead of scrolling the viewport.
|
||
// Live streams are stripped at the source, but buffers persisted BEFORE that
|
||
// strip existed can still carry them; strip on replay for parity.
|
||
// eslint-disable-next-line no-control-regex
|
||
const MOUSE_TRACKING_PATTERN = /\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g;
|
||
|
||
/**
|
||
* Strip redundant Ink spinner/status-bar redraw frames from the terminal buffer.
|
||
* Ink (Claude Code's TUI) uses absolute cursor positioning (CSI n d = VPA) to animate
|
||
* the spinner and update the status bar. During long thinking phases, these frames
|
||
* accumulate to 500KB+ of repeated overwrites to the same rows.
|
||
*
|
||
* Strategy: detect "redraw clusters" — dense runs of VPA escapes where each is within
|
||
* FRAME_GAP bytes of the previous (i.e. continuous rerendering of the same UI region).
|
||
* Collapse each big cluster down to just the bytes from its last VPA onwards (the final
|
||
* frame). Content *between* clusters (Claude's streamed response text) is preserved.
|
||
*
|
||
* Without clustering, a single first-VPA-finds-all approach would discard the entire
|
||
* conversation after Claude's first render — losing 100KB+ of legitimate scrollback.
|
||
*/
|
||
export function stripInkRedrawBloat(buffer: string): string {
|
||
// eslint-disable-next-line no-control-regex
|
||
const vpaRe = /\x1b\[\d+d/g;
|
||
const positions: number[] = [];
|
||
let m: RegExpExecArray | null;
|
||
while ((m = vpaRe.exec(buffer)) !== null) {
|
||
positions.push(m.index);
|
||
}
|
||
if (positions.length < 10) return buffer; // Too few VPAs to be bloat
|
||
|
||
// Group consecutive VPAs into clusters separated by gaps > FRAME_GAP.
|
||
// Within a cluster, VPAs are close together (continuous rerenders).
|
||
// Between clusters, real terminal output (response text) lives.
|
||
const FRAME_GAP = 8 * 1024; // 8KB — one Ink frame is typically 1-4KB
|
||
const MIN_BLOAT_SIZE = 32 * 1024; // Only collapse clusters spanning >= 32KB
|
||
|
||
const clusters: { start: number; end: number }[] = [];
|
||
let cs = positions[0];
|
||
let ce = positions[0];
|
||
for (let i = 1; i < positions.length; i++) {
|
||
if (positions[i] - ce <= FRAME_GAP) {
|
||
ce = positions[i];
|
||
} else {
|
||
clusters.push({ start: cs, end: ce });
|
||
cs = positions[i];
|
||
ce = positions[i];
|
||
}
|
||
}
|
||
clusters.push({ start: cs, end: ce });
|
||
|
||
// For each big cluster, replace [start..end] with the bytes from `end` onwards
|
||
// (which contains the last frame's content up to where the next cluster, or
|
||
// post-cluster content, begins).
|
||
const parts: string[] = [];
|
||
let cursor = 0;
|
||
for (const cl of clusters) {
|
||
if (cl.end - cl.start < MIN_BLOAT_SIZE) continue;
|
||
parts.push(buffer.slice(cursor, cl.start));
|
||
cursor = cl.end;
|
||
}
|
||
parts.push(buffer.slice(cursor));
|
||
return parts.join('');
|
||
}
|
||
|
||
/**
|
||
* Validate image bytes against a declared extension. Sniffs the first ~12 bytes
|
||
* for a known magic-number signature. Defends against polyglots (e.g. HTML or
|
||
* SVG disguised under a `Content-Type: image/png` header) and against simple
|
||
* extension-only spoofing — both the multipart filename and the Content-Type
|
||
* are attacker-controlled, the raw bytes are not.
|
||
*
|
||
* Signatures: https://en.wikipedia.org/wiki/List_of_file_signatures
|
||
*/
|
||
export function imageMagicMatchesExt(data: Buffer, ext: string): boolean {
|
||
if (data.length < 12) return false;
|
||
const u32be = (off: number): number => data.readUInt32BE(off);
|
||
switch (ext) {
|
||
case '.png':
|
||
return u32be(0) === 0x89504e47 && u32be(4) === 0x0d0a1a0a;
|
||
case '.jpg':
|
||
case '.jpeg':
|
||
return data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff;
|
||
case '.gif':
|
||
return (
|
||
data[0] === 0x47 &&
|
||
data[1] === 0x49 &&
|
||
data[2] === 0x46 &&
|
||
data[3] === 0x38 &&
|
||
(data[4] === 0x37 || data[4] === 0x39) &&
|
||
data[5] === 0x61
|
||
);
|
||
case '.webp':
|
||
// RIFF....WEBP
|
||
return u32be(0) === 0x52494646 && u32be(8) === 0x57454250;
|
||
case '.bmp':
|
||
return data[0] === 0x42 && data[1] === 0x4d;
|
||
case '.heic':
|
||
case '.heif': {
|
||
// ISO Base Media File Format: size + "ftyp" + major brand. The brand
|
||
// list matches heic-decode's own isHeic() — accepting more brands here
|
||
// would only route bytes into a conversion that always throws.
|
||
if (u32be(4) !== 0x66747970) return false;
|
||
const brand = data.subarray(8, 12).toString('ascii');
|
||
return ['heic', 'heix', 'hevc', 'hevx', 'mif1', 'msf1'].includes(brand);
|
||
}
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Per-(IP, sessionId) token bucket for paste-image. 30 requests/minute.
|
||
// Bucket map entries are pruned when they drift > 1h stale to bound memory
|
||
// against a flood of unique IP keys.
|
||
const PASTE_RATE_TOKENS = 30;
|
||
const PASTE_RATE_REFILL_PER_MS = PASTE_RATE_TOKENS / 60_000;
|
||
const PASTE_BUCKET_TTL_MS = 60 * 60 * 1000;
|
||
const PASTE_BUCKET_GC_THRESHOLD = 1000;
|
||
const pasteRateBuckets = new Map<string, { tokens: number; lastRefill: number }>();
|
||
|
||
export function consumePasteToken(key: string, now: number = Date.now()): boolean {
|
||
if (pasteRateBuckets.size > PASTE_BUCKET_GC_THRESHOLD) {
|
||
for (const [k, b] of pasteRateBuckets) {
|
||
if (now - b.lastRefill > PASTE_BUCKET_TTL_MS) pasteRateBuckets.delete(k);
|
||
}
|
||
}
|
||
let b = pasteRateBuckets.get(key);
|
||
if (!b) {
|
||
b = { tokens: PASTE_RATE_TOKENS, lastRefill: now };
|
||
pasteRateBuckets.set(key, b);
|
||
}
|
||
const delta = (now - b.lastRefill) * PASTE_RATE_REFILL_PER_MS;
|
||
b.tokens = Math.min(PASTE_RATE_TOKENS, b.tokens + delta);
|
||
b.lastRefill = now;
|
||
if (b.tokens < 1) return false;
|
||
b.tokens -= 1;
|
||
return true;
|
||
}
|
||
|
||
// Test hook: reset between runs.
|
||
export function _resetPasteRateBuckets(): void {
|
||
pasteRateBuckets.clear();
|
||
}
|
||
|
||
/**
|
||
* Security (multi-user §6.3): the Claude-only permission-mode downgrade does not
|
||
* cover the other CLIs' bypass switches. Codex `--dangerously-bypass-approvals-and-sandbox`,
|
||
* Gemini `--approval-mode yolo`, and Antigravity `--dangerously-skip-permissions` disable
|
||
* the safety classifier the non-granted-user downgrade is meant to keep on, so clamp them
|
||
* for a non-granted owner. buildGeminiCommand defaults an ABSENT approvalMode to yolo, so
|
||
* the gemini config must be MATERIALIZED (auto_edit) even when the request sent none.
|
||
* Antigravity is like Codex: an ABSENT config already defaults safe (no bypass flag), so
|
||
* only a sent config needs the flag forced off. No-op in single-user mode / for a granted
|
||
* owner (canUsernameRunPrivilegedCommands returns true when !isMultiUserMode()).
|
||
*/
|
||
async function clampExternalCliBypassForOwner(
|
||
owner: string | undefined,
|
||
codexConfig: CodexConfig | undefined,
|
||
geminiConfig: GeminiConfig | undefined,
|
||
antigravityConfig: AntigravityConfig | undefined
|
||
): Promise<{
|
||
codexConfig: CodexConfig | undefined;
|
||
geminiConfig: GeminiConfig | undefined;
|
||
antigravityConfig: AntigravityConfig | undefined;
|
||
}> {
|
||
const granted = await canUsernameRunPrivilegedCommands(owner);
|
||
if (granted) return { codexConfig, geminiConfig, antigravityConfig };
|
||
// Non-granted: force codex/antigravity bypass off (only meaningful when a config was
|
||
// sent) and materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default).
|
||
const clampedCodex = codexConfig ? { ...codexConfig, dangerouslyBypassApprovals: false } : codexConfig;
|
||
const clampedGemini: GeminiConfig = { ...(geminiConfig ?? {}), approvalMode: 'auto_edit' };
|
||
const clampedAntigravity = antigravityConfig
|
||
? { ...antigravityConfig, dangerouslySkipPermissions: false }
|
||
: antigravityConfig;
|
||
return { codexConfig: clampedCodex, geminiConfig: clampedGemini, antigravityConfig: clampedAntigravity };
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Agent wait helpers (shared by GET /wait, GET /wait-output, POST /input)
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* Validate a wait query WITHOUT throwing away the Zod issue.
|
||
*
|
||
* `parseBody`'s message argument REPLACES the issue text, so `?timeout=30s` came
|
||
* back as a bare "Invalid wait parameters": the caller could not tell which of
|
||
* `until`, `timeout` or `fresh` it got wrong, and its only move was to retry with
|
||
* a different guess. These endpoints are driven by an LLM with no documentation in
|
||
* context — the error message IS the documentation, which is why the signal parser
|
||
* one line later goes to the trouble of naming the bad token and listing the valid
|
||
* ones. This keeps the endpoint label AND names the offending field.
|
||
*/
|
||
function parseWaitQuery<T>(schema: z.ZodType<T>, query: unknown, label: string): T {
|
||
const result = schema.safeParse(query);
|
||
if (result.success) return result.data;
|
||
const issue = result.error.issues[0];
|
||
const field = issue && issue.path.length > 0 ? issue.path.join('.') : '';
|
||
const detail = issue?.message ?? 'validation failed';
|
||
const message = field ? `Invalid ${label} parameter '${field}': ${detail}` : `Invalid ${label} parameters: ${detail}`;
|
||
throw Object.assign(new Error(message), {
|
||
statusCode: 400,
|
||
body: createErrorResponse(ApiErrorCode.INVALID_INPUT, message),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Map a waiter-cap rejection to the code that tells the caller the truth.
|
||
*
|
||
* The two caps mean different things and warrant different recovery: `session` is
|
||
* genuinely about THIS session, while `owner` and `total` are process-wide budgets
|
||
* that say nothing about it. Reporting a global cap as `SESSION_BUSY` (409,
|
||
* documented as "Session is busy") sent an agent off to a different session to hit
|
||
* the identical error. `RATE_LIMITED` is the code whose whole meaning is "come back
|
||
* later", and clients and proxies already treat 429 that way.
|
||
*/
|
||
function waitCapacityResponse(err: WaitCapacityError): ApiResponse<never> {
|
||
const code = err.scope === 'session' ? ApiErrorCode.SESSION_BUSY : ApiErrorCode.RATE_LIMITED;
|
||
// The registry's message already names the scope and the limit; passing it through
|
||
// verbatim keeps the wording in one place.
|
||
return createErrorResponse(code, err.message);
|
||
}
|
||
|
||
/**
|
||
* The signal a session is ALREADY emitting, corrected for liveness.
|
||
*
|
||
* `signalForStatus` alone is not enough here, because `Session` parks a DEAD PTY at
|
||
* `_status = 'idle'` (both `onExit` handlers do) and the object survives in the
|
||
* session map until an explicit DELETE. Trusting the status therefore answers the
|
||
* default wait with `{signal:"idle", immediate:true}` for a worker that has
|
||
* crashed — HTTP 200, no error anywhere, and the agent types its next prompt into a
|
||
* corpse — while `until=exit` blocks for the full timeout on an event that already
|
||
* happened and can never happen again.
|
||
*
|
||
* `pid === null` means no process is behind this session: it exited, it was
|
||
* detached, or it was created and never started. All three are `exit` from a
|
||
* caller's point of view — nothing is running — and in all three the agent's
|
||
* correct next move is to (re)start the worker rather than to type at it. The
|
||
* response still carries the raw `status` alongside, so nothing is hidden.
|
||
*
|
||
* ⚠️ `pid` alone is NOT enough, and on the normal configuration it is never the
|
||
* thing that fires — see `workerIsDead()`. `dead` carries the mux layer's answer.
|
||
*
|
||
* Fixing it HERE rather than in `signalForStatus` is deliberate: liveness is not
|
||
* derivable from `SessionStatus`, and the registry holds no `Session` reference.
|
||
*/
|
||
function currentSignalFor(session: { pid: number | null; status: SessionStatus }, dead: boolean): WaitSignal | null {
|
||
if (dead || session.pid === null || session.pid === undefined) return 'exit';
|
||
return signalForStatus(session.status);
|
||
}
|
||
|
||
// ── Worker liveness for tmux-backed sessions ────────────────────────────────
|
||
//
|
||
// `session.pid` is the LOCAL `tmux attach` client, not the worker. Codeman sets
|
||
// `remain-on-exit on` for every session it creates, so when the command inside the
|
||
// pane exits, tmux keeps the pane (`pane_dead=1`), the tmux session survives, the
|
||
// attach client keeps running and `pid` never goes null — no `exit` event is emitted
|
||
// and nothing in `Session` changes. Measured on a shell worker killed with `exit 42`:
|
||
// tmux reports `pane_dead=1 status=42` while Codeman reports `pid=309406 status=idle`
|
||
// and the DEFAULT wait answers `{signal:"idle", immediate:true}` in 0 ms for a corpse.
|
||
// So the liveness check has to ask the mux layer. `pid === null` still matters: it is
|
||
// the right (and only) answer for a direct-PTY session, which has no pane to ask about.
|
||
//
|
||
// Cost control, because `isPaneDead()` is a synchronous `execSync` and `/wait` is
|
||
// polled in a loop by design:
|
||
// 1. Only mux-backed sessions are probed at all.
|
||
// 2. Only requests that actually wait probe — a plain `POST .../input` (the browser's
|
||
// hot path, thousands per session) never touches tmux.
|
||
// 3. Results are cached per pane for PANE_DEATH_TTL_MS, so a poll loop cannot turn
|
||
// into one exec per request.
|
||
// 4. The while-blocked watcher is ONE timer per session no matter how many waiters
|
||
// are parked on it, and it exists only while at least one of them is.
|
||
|
||
/** How long a pane-liveness probe is reused. Long enough to absorb a poll loop. */
|
||
const PANE_DEATH_TTL_MS = 750;
|
||
|
||
/** How often a session with a parked waiter is re-checked for a dead worker. */
|
||
const PANE_DEATH_POLL_MS = 3_000;
|
||
|
||
/** Bounded, because a 24h server churns through panes. */
|
||
const paneDeathCache = new LRUMap<string, { dead: boolean; at: number }>({ maxSize: 256 });
|
||
|
||
/** One watcher per pane, refcounted by the waits currently parked on it. */
|
||
const paneDeathWatchers = new Map<string, { timer: NodeJS.Timeout; refs: number }>();
|
||
|
||
type LivenessSession = { usesMux?: boolean; muxName?: string | null };
|
||
|
||
/**
|
||
* Whether the worker inside this session's tmux pane has exited.
|
||
*
|
||
* False for anything not tmux-backed (nothing to ask), and false when the probe is
|
||
* unavailable or throws — an unknown answer must never invent a death.
|
||
*/
|
||
function workerIsDead(mux: InfraPort['mux'], session: LivenessSession, now: number = Date.now()): boolean {
|
||
const muxName = session.usesMux === false ? null : session.muxName;
|
||
if (!muxName) return false;
|
||
// Defensive: `TerminalMultiplexer` declares it, but route-test doubles may not.
|
||
if (typeof mux?.isPaneDead !== 'function') return false;
|
||
|
||
const cached = paneDeathCache.get(muxName);
|
||
if (cached && now - cached.at < PANE_DEATH_TTL_MS) return cached.dead;
|
||
|
||
let dead = false;
|
||
try {
|
||
dead = mux.isPaneDead(muxName) === true;
|
||
} catch {
|
||
dead = false;
|
||
}
|
||
paneDeathCache.set(muxName, { dead, at: now });
|
||
return dead;
|
||
}
|
||
|
||
/**
|
||
* Release every waiter on a session whose worker has died, in the documented order.
|
||
*
|
||
* The same pair the PTY-exit listener and the delete path use, for the same reason:
|
||
* `until=exit` callers get their signal, everyone else gets `ended: true` instead of
|
||
* burning the rest of their timeout on feeds that will never produce anything.
|
||
*/
|
||
function releaseWaitersForDeadWorker(sessionId: string): void {
|
||
sessionWaits.notifySignal(sessionId, 'exit');
|
||
sessionWaits.cancelAll(sessionId);
|
||
}
|
||
|
||
/**
|
||
* While a wait is parked on a mux-backed session, poll for the worker dying.
|
||
*
|
||
* Without this, a worker that dies DURING a wait is invisible: no `exit` event fires
|
||
* (the attach client is still alive), no output arrives, and the caller blocks for its
|
||
* full timeout — the common orchestration case, "send a prompt and wait", where the
|
||
* worker crashes mid-turn.
|
||
*
|
||
* @returns a release function; call it in a `finally`, or the timer outlives the wait.
|
||
*/
|
||
function watchForDeadWorker(mux: InfraPort['mux'], session: LivenessSession, sessionId: string): () => void {
|
||
const muxName = session.usesMux === false ? null : session.muxName;
|
||
if (!muxName || typeof mux?.isPaneDead !== 'function') return () => {};
|
||
|
||
const existing = paneDeathWatchers.get(muxName);
|
||
if (existing) {
|
||
existing.refs++;
|
||
} else {
|
||
const timer = setInterval(() => {
|
||
if (!workerIsDead(mux, session)) return;
|
||
releaseWaitersForDeadWorker(sessionId);
|
||
}, PANE_DEATH_POLL_MS);
|
||
// Auxiliary to the waiter's own timer, which is deliberately NOT unref'd; this one
|
||
// must never be the reason the process stays up.
|
||
timer.unref();
|
||
paneDeathWatchers.set(muxName, { timer, refs: 1 });
|
||
}
|
||
|
||
let released = false;
|
||
return () => {
|
||
if (released) return;
|
||
released = true;
|
||
const entry = paneDeathWatchers.get(muxName);
|
||
if (!entry) return;
|
||
entry.refs--;
|
||
if (entry.refs <= 0) {
|
||
clearInterval(entry.timer);
|
||
paneDeathWatchers.delete(muxName);
|
||
}
|
||
};
|
||
}
|
||
|
||
/** Test seam: pane-liveness state is module-level, so a suite must be able to reset it. */
|
||
export function _resetPaneLivenessState(): void {
|
||
for (const entry of paneDeathWatchers.values()) clearInterval(entry.timer);
|
||
paneDeathWatchers.clear();
|
||
paneDeathCache.clear();
|
||
}
|
||
|
||
/** Test seam: how many panes are currently being watched for a dead worker. */
|
||
export function _paneDeathWatcherCount(): number {
|
||
return paneDeathWatchers.size;
|
||
}
|
||
|
||
/**
|
||
* An `AbortController` that fires when the CLIENT goes away, and only then.
|
||
*
|
||
* Freeing an abandoned waiter matters because the documented pattern is a loop of
|
||
* short waits: `curl --max-time 30 ".../wait?timeout=600000"` abandons a live waiter
|
||
* every iteration until the cap is hit and an innocent session reports busy. Same for
|
||
* any proxy that cuts the connection.
|
||
*
|
||
* ⚠️ **It must listen on the RESPONSE, not the request.** `req.raw` emits `'close'`
|
||
* as soon as the request body has finished streaming, which on a POST is BEFORE the
|
||
* handler ever blocks — measured at +1ms with `aborted: false`, indistinguishable
|
||
* from a real hang-up at +0ms. Wiring the abort there cancels every send-and-wait
|
||
* instantly and silently kills the feature (it survives on GET only because a GET has
|
||
* no body to finish). `reply.raw` emits `'close'` both when the response completes
|
||
* and when the socket dies, and `writableFinished` is what tells those apart: true
|
||
* only if the response actually went out. The guard is load-bearing, not defensive.
|
||
*
|
||
* `app.inject()` never emits `'close'` at all, so this is only observable over real
|
||
* HTTP — which is why the regression test for it binds a port.
|
||
*/
|
||
function abortOnClientHangUp(reply: FastifyReply): AbortController {
|
||
const controller = new AbortController();
|
||
reply.raw.on('close', () => {
|
||
if (!reply.raw.writableFinished) controller.abort();
|
||
});
|
||
return controller;
|
||
}
|
||
|
||
/**
|
||
* Inject the agent skill into a case on create, surfacing only the REFUSALS.
|
||
*
|
||
* `applyAgentSkill` declines two shapes rather than writing through them ('foreign':
|
||
* an unmarked skills/codeman the user authored; 'symlink': the skill dir or its
|
||
* parent is a link). Both were silent: the user flips `agentSkillEnabled` on, nothing
|
||
* appears in the case, and there is nowhere to look for why. The ordinary outcomes
|
||
* ('installed'/'refreshed'/'unchanged') stay unlogged since they would print on every
|
||
* single session create.
|
||
*
|
||
* Injection is best-effort and stays that way: neither a refusal nor a thrown error
|
||
* may fail the create.
|
||
*/
|
||
async function injectAgentSkill(casePath: string): Promise<void> {
|
||
const skillDir = join(casePath, '.claude', 'skills', 'codeman');
|
||
try {
|
||
const result = await applyAgentSkill(casePath, true);
|
||
if (result === 'foreign') {
|
||
console.warn(
|
||
`[agent-skill] not injected: ${skillDir} exists but is not Codeman-managed (no marker), refusing to touch it. Remove that copy if you want the packaged skill there.`
|
||
);
|
||
} else if (result === 'symlink') {
|
||
console.warn(
|
||
`[agent-skill] not injected: ${skillDir} (or its parent) is a symlink, refusing to write through it. Replace it with a real directory to let Codeman install the skill.`
|
||
);
|
||
}
|
||
} catch (err: unknown) {
|
||
console.warn(`[agent-skill] injection failed for ${skillDir}: ${getErrorMessage(err)}`);
|
||
}
|
||
}
|
||
|
||
export function registerSessionRoutes(
|
||
app: FastifyInstance,
|
||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
|
||
): void {
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Auth
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
// ========== Logout ==========
|
||
|
||
app.post('/api/logout', async (req, reply) => {
|
||
// Invalidate server-side session token (not just the browser cookie)
|
||
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
|
||
if (sessionToken) {
|
||
ctx.authSessions?.delete(sessionToken);
|
||
}
|
||
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
|
||
return {};
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Session CRUD (list, create, rename, color, delete, detail)
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
// ========== Session Listing ==========
|
||
|
||
app.get('/api/sessions', async (req) => {
|
||
const list = ctx.getLightSessionsState();
|
||
if (!isMultiUserMode()) return list;
|
||
const user = getAuthUser(req);
|
||
if (user.role === 'admin') return list;
|
||
return (list as Array<{ owner?: string }>).filter((s) => canAccessOwned(user, s.owner));
|
||
});
|
||
|
||
// ========== Session Tab Order (global sync, COD-131) ==========
|
||
|
||
app.put('/api/session-order', async (req): Promise<ApiResponse<{ order: string[] }>> => {
|
||
const { order } = parseBody(SessionOrderUpdateSchema, req.body, 'Invalid session order');
|
||
// Server is authoritative but never drops ids it knows about that the
|
||
// pushing device hadn't loaded yet — those fall to the end (mergeSessionOrder).
|
||
const merged = mergeSessionOrder(order, ctx.store.getSessionOrder());
|
||
ctx.store.setSessionOrder(merged);
|
||
ctx.broadcast(SseEvent.SessionOrderChanged, { order: merged });
|
||
return { success: true, data: { order: merged } };
|
||
});
|
||
|
||
// ========== Session Creation ==========
|
||
|
||
app.post('/api/sessions', async (req) => {
|
||
const owner = ownerFor(req);
|
||
// Global + per-user session cap.
|
||
const capMsg = sessionCapacityMessage(ctx.sessions, owner);
|
||
if (capMsg) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, capMsg);
|
||
|
||
const body = parseBody(CreateSessionSchema, req.body);
|
||
let workingDir = body.workingDir || process.cwd();
|
||
let remote = undefined;
|
||
|
||
// COD-105 — attach to a discovered (non-owned) remote tmux session. The
|
||
// remote session is already running, so we skip the tmux-prereq probe and
|
||
// build a NON-owned SessionRemote (detach-not-kill on close). Remote CASE
|
||
// creation (owned durable sessions) is handled by the dedicated case-create
|
||
// endpoint below, which #145 consolidated remote-host resolution into.
|
||
if (body.attachRemoteSession) {
|
||
const { hostId, remoteSessionName } = body.attachRemoteSession;
|
||
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === hostId);
|
||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||
workingDir = `${host.username}@${host.host}:${remoteSessionName}`;
|
||
remote = toAttachedSessionRemote(host, remoteSessionName, workingDir);
|
||
}
|
||
|
||
// Multi-user: shell mode is arbitrary command execution as the host account,
|
||
// gated behind the same grant as bypass (section 6.3). Resolve the owner's grant
|
||
// from the store so a GRANTED regular user is not wrongly denied (AuthUser role alone can't tell).
|
||
if (body.mode === 'shell' && !(await canUsernameRunPrivilegedCommands(owner))) {
|
||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant');
|
||
}
|
||
|
||
// Multi-user linchpin (section 6.2): a non-admin's workingDir must resolve
|
||
// inside their own case space. Enforced BEFORE any disk-mutating call below so
|
||
// a foreign path can never be written into.
|
||
if (!isWorkingDirAllowed(getAuthUser(req), workingDir)) {
|
||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
|
||
}
|
||
|
||
// Validate workingDir exists and is a directory
|
||
if (body.workingDir) {
|
||
try {
|
||
const stat = statSync(workingDir);
|
||
if (!stat.isDirectory()) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
|
||
}
|
||
} catch {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
|
||
}
|
||
}
|
||
|
||
// envOverrides flow through Session → tmux setenv (ephemeral, per-session).
|
||
//
|
||
// For keys the caller is actively setting, strip any stale disk entry a prior
|
||
// Codeman version may have written. Scope limited to:
|
||
// - Claude mode (OpenCode/Codex/Gemini/Antigravity don't read .claude/settings.local.json)
|
||
// - workingDir inside CASES_DIR / the per-user case space (Codeman's managed
|
||
// territory — we never mutate .claude/settings.local.json in arbitrary user
|
||
// repos that POST /api/sessions can target, as those may have hand-authored
|
||
// values).
|
||
const managedCasesBase = resolveCasesDir(getAuthUser(req));
|
||
const canStripDisk =
|
||
body.mode !== 'opencode' &&
|
||
body.mode !== 'codex' &&
|
||
body.mode !== 'gemini' &&
|
||
body.mode !== 'antigravity' &&
|
||
body.envOverrides &&
|
||
Object.keys(body.envOverrides).length > 0 &&
|
||
(workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
|
||
if (canStripDisk) {
|
||
await stripCaseEnvKeys(workingDir, Object.keys(body.envOverrides!));
|
||
}
|
||
|
||
// Write model override to .claude/settings.local.json if provided
|
||
if (body.modelOverride !== undefined) {
|
||
await updateCaseModel(workingDir, body.modelOverride || null);
|
||
}
|
||
|
||
// Plan-usage statusLine exporter (App Settings → Display → "Plan Usage
|
||
// Limits"). Claude-only; runs for ANY working dir (linked cases / real repos,
|
||
// where most sessions live), mirroring updateCaseModel above.
|
||
//
|
||
// ADD-ONLY: we never remove on create. Sessions in a repo share one
|
||
// settings.local.json, so a single create-with-false (e.g. a client whose
|
||
// synced setting hadn't loaded yet) must NOT yank the statusLine out from
|
||
// under other live sessions in that repo — that breaks their footer + the
|
||
// chip's data feed for everyone. The exporter is benign when the chip is off
|
||
// (the footer just shows session status). isOurs-guarded so a user's own
|
||
// statusLine is never touched.
|
||
if ((body.mode ?? 'claude') === 'claude' && body.statusLineTelemetry === true) {
|
||
await applyStatusLineConfig(workingDir, true);
|
||
}
|
||
|
||
// COD-91 self-heal: refresh a pre-secret hooks block in an existing case so the now
|
||
// unconditional hook-secret gate keeps accepting its hook events. No-op for fresh
|
||
// cases (writeHooksConfig already wrote the secret) and for non-Codeman/absent hooks.
|
||
if ((body.mode ?? 'claude') === 'claude') {
|
||
await refreshStaleCodemanHooks(workingDir).catch(() => {});
|
||
// Agent skill (docs/agent-control-plan.md §2): ADD-ONLY on create, same shared-
|
||
// .claude rationale as the statusLine above: a create must never remove the
|
||
// skill from under other live sessions in the repo. Marker-guarded, so a
|
||
// user's own skills/codeman is never touched.
|
||
if (await ctx.getAgentSkillEnabled()) {
|
||
await injectAgentSkill(workingDir);
|
||
}
|
||
}
|
||
|
||
// Check OpenCode availability if requested
|
||
if (body.mode === 'opencode') {
|
||
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
||
if (!isOpenCodeAvailable()) {
|
||
return createErrorResponse(
|
||
ApiErrorCode.OPERATION_FAILED,
|
||
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
||
);
|
||
}
|
||
}
|
||
|
||
// Check Codex availability if requested
|
||
if (body.mode === 'codex') {
|
||
const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
|
||
if (!isCodexAvailable()) {
|
||
return createErrorResponse(
|
||
ApiErrorCode.OPERATION_FAILED,
|
||
'Codex CLI not found. Install with: npm install -g @openai/codex'
|
||
);
|
||
}
|
||
}
|
||
|
||
// Check Gemini availability if requested
|
||
if (body.mode === 'gemini') {
|
||
const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
|
||
if (!isGeminiAvailable()) {
|
||
return createErrorResponse(
|
||
ApiErrorCode.OPERATION_FAILED,
|
||
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
||
);
|
||
}
|
||
}
|
||
if (body.mode === 'antigravity') {
|
||
const { isAntigravityAvailable } = await import('../../utils/antigravity-cli-resolver.js');
|
||
if (!isAntigravityAvailable()) {
|
||
return createErrorResponse(
|
||
ApiErrorCode.OPERATION_FAILED,
|
||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
||
);
|
||
}
|
||
}
|
||
|
||
// Pre-validate resumeSessionId: check that the conversation file actually exists
|
||
// in Claude's projects directory. If not, skip resume to avoid confusing
|
||
// "No conversation found" errors from Claude CLI.
|
||
let validatedResumeId = body.resumeSessionId;
|
||
if (validatedResumeId) {
|
||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||
let found = false;
|
||
try {
|
||
const projectDirs = await fs.readdir(projectsDir);
|
||
for (const projDir of projectDirs) {
|
||
const sessionFile = join(projectsDir, projDir, `${validatedResumeId}.jsonl`);
|
||
try {
|
||
const stat = await fs.stat(sessionFile);
|
||
if (stat.size > 4000) {
|
||
found = true;
|
||
break;
|
||
}
|
||
} catch {
|
||
// File doesn't exist in this project dir
|
||
}
|
||
}
|
||
} catch {
|
||
// Projects dir doesn't exist
|
||
}
|
||
if (!found) {
|
||
console.log(`[Session] Resume session ${validatedResumeId} not found on disk, starting fresh`);
|
||
validatedResumeId = undefined;
|
||
}
|
||
}
|
||
|
||
const globalNice = await ctx.getGlobalNiceConfig();
|
||
const modelConfig = await ctx.getModelConfig();
|
||
const mode = body.mode || 'claude';
|
||
const model =
|
||
mode === 'opencode'
|
||
? body.openCodeConfig?.model
|
||
: mode === 'codex'
|
||
? body.codexConfig?.model
|
||
: mode === 'gemini'
|
||
? body.geminiConfig?.model
|
||
: mode === 'antigravity'
|
||
? body.antigravityConfig?.model
|
||
: mode !== 'shell'
|
||
? modelConfig?.defaultModel || undefined
|
||
: undefined;
|
||
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
||
// Section 6.3: force non-granted users to a classifier-guarded mode.
|
||
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner);
|
||
// Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted).
|
||
const {
|
||
codexConfig: gatedCodexConfig,
|
||
geminiConfig: gatedGeminiConfig,
|
||
antigravityConfig: gatedAntigravityConfig,
|
||
} = await clampExternalCliBypassForOwner(owner, body.codexConfig, body.geminiConfig, body.antigravityConfig);
|
||
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||
const session = new Session({
|
||
workingDir,
|
||
mode,
|
||
name: body.name || '',
|
||
mux: ctx.mux,
|
||
useMux: true,
|
||
niceConfig: globalNice,
|
||
model,
|
||
claudeMode: effectiveClaudeMode,
|
||
allowedTools: claudeModeConfig.allowedTools,
|
||
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
|
||
codexConfig: mode === 'codex' ? gatedCodexConfig : undefined,
|
||
geminiConfig: mode === 'gemini' ? gatedGeminiConfig : undefined,
|
||
antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
|
||
resumeSessionId: validatedResumeId,
|
||
envOverrides: body.envOverrides,
|
||
effort: body.effort,
|
||
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
|
||
remote,
|
||
owner,
|
||
parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner),
|
||
});
|
||
|
||
ctx.addSession(session);
|
||
ctx.store.incrementSessionsCreated();
|
||
ctx.persistSessionState(session);
|
||
await ctx.setupSessionListeners(session);
|
||
getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name });
|
||
|
||
// Use light state for broadcast + response — buffers are fetched on-demand via /terminal.
|
||
// Avoids serializing 2-3MB of terminal+text buffers per session creation.
|
||
const lightState = ctx.getSessionStateWithRespawn(session);
|
||
ctx.broadcast(SseEvent.SessionCreated, lightState);
|
||
return { session: lightState };
|
||
});
|
||
|
||
// ========== Rename Session ==========
|
||
|
||
app.put('/api/sessions/:id/name', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const body = parseBody(SessionNameSchema, req.body, 'Invalid request body');
|
||
const session = findSessionOrFail(ctx, id, req);
|
||
|
||
const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH);
|
||
session.name = name;
|
||
// Also update the mux session name if applicable
|
||
ctx.mux.updateSessionName(id, session.name);
|
||
persistAndBroadcastSession(ctx, session);
|
||
return { name: session.name };
|
||
});
|
||
|
||
// ========== Set Session Color ==========
|
||
|
||
app.put('/api/sessions/:id/color', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const body = parseBody(SessionColorSchema, req.body, 'Invalid request body');
|
||
const session = findSessionOrFail(ctx, id, req);
|
||
|
||
const validColors = ['default', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink'];
|
||
if (!validColors.includes(body.color)) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid color');
|
||
}
|
||
|
||
session.setColor(body.color as SessionColor);
|
||
persistAndBroadcastSession(ctx, session);
|
||
return { color: session.color };
|
||
});
|
||
|
||
// ========== Delete Session ==========
|
||
|
||
app.delete('/api/sessions/:id', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const query = req.query as { killMux?: string };
|
||
const killMux = query.killMux !== 'false'; // Default to true
|
||
|
||
// Security: owner-scoped lookup 404s foreign/missing sessions uniformly (no existence leak, no cross-user kill).
|
||
const session = findSessionOrFail(ctx, id, req);
|
||
|
||
await ctx.cleanupSession(session.id, killMux, 'user_delete');
|
||
return {};
|
||
});
|
||
|
||
// ========== Delete All Sessions ==========
|
||
|
||
app.delete('/api/sessions', async (req): Promise<ApiResponse<{ killed: number }>> => {
|
||
// Security: scope the bulk sweep to sessions the caller can access — a non-admin
|
||
// must not wipe other users' sessions (canAccessOwned is allow-all for admin/single-user).
|
||
const user = getAuthUser(req);
|
||
const sessionIds = Array.from(ctx.sessions.values())
|
||
.filter((s) => canAccessOwned(user, s.owner))
|
||
.map((s) => s.id);
|
||
let killed = 0;
|
||
|
||
for (const id of sessionIds) {
|
||
if (ctx.sessions.has(id)) {
|
||
await ctx.cleanupSession(id, true, 'user_bulk_delete');
|
||
killed++;
|
||
}
|
||
}
|
||
|
||
return { success: true, data: { killed } };
|
||
});
|
||
|
||
// ========== Get Session Detail ==========
|
||
|
||
app.get('/api/sessions/:id', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const session = findSessionOrFail(ctx, id, req);
|
||
|
||
// Use light state (no full buffers) — terminal buffer available via /terminal endpoint.
|
||
// Full buffers were 2-3MB and caused slowness when polled frequently (e.g. Ralph wizard).
|
||
return ctx.getSessionStateWithRespawn(session);
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Session Data (output, ralph state, run summary, active tools)
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
// ========== Get Session Output ==========
|
||
|
||
app.get('/api/sessions/:id/output', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const session = findSessionOrFail(ctx, id, req);
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
textOutput: session.textOutput,
|
||
messages: session.messages,
|
||
errorBuffer: session.errorBuffer,
|
||
},
|
||
};
|
||
});
|
||
|
||
// ========== Get Ralph State ==========
|
||
|
||
app.get('/api/sessions/:id/ralph-state', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const session = findSessionOrFail(ctx, id, req);
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
loop: session.ralphLoopState,
|
||
todos: session.ralphTodos,
|
||
todoStats: session.ralphTodoStats,
|
||
},
|
||
};
|
||
});
|
||
|
||
// ========== Get Run Summary ==========
|
||
|
||
app.get('/api/sessions/:id/run-summary', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const session = findSessionOrFail(ctx, id, req);
|
||
|
||
const tracker = ctx.runSummaryTrackers.get(id);
|
||
if (!tracker) {
|
||
// Create a fresh tracker if one doesn't exist (shouldn't happen normally)
|
||
const newTracker = new RunSummaryTracker(id, session.name);
|
||
ctx.runSummaryTrackers.set(id, newTracker);
|
||
return { summary: newTracker.getSummary() };
|
||
}
|
||
|
||
// Update session name in case it changed
|
||
tracker.setSessionName(session.name);
|
||
|
||
return { summary: tracker.getSummary() };
|
||
});
|
||
|
||
// ========== Get Active Tools ==========
|
||
|
||
app.get('/api/sessions/:id/active-tools', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const session = findSessionOrFail(ctx, id, req);
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
tools: session.activeTools,
|
||
},
|
||
};
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Session Execution (run prompt, interactive mode, shell mode)
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
// ========== Run Prompt ==========
|
||
|
||
app.post('/api/sessions/:id/run', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const { prompt } = parseBody(RunPromptSchema, req.body);
|
||
const session = findSessionOrFail(ctx, id, req);
|
||
|
||
if (session.isBusy()) {
|
||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||
}
|
||
|
||
// Run async, don't wait
|
||
session.runPrompt(prompt).catch((err) => {
|
||
ctx.broadcast(SseEvent.SessionError, { id, error: err.message });
|
||
});
|
||
|
||
ctx.broadcast(SseEvent.SessionRunning, { id, prompt });
|
||
return {};
|
||
});
|
||
|
||
// ========== Start Interactive Mode ==========
|
||
|
||
app.post('/api/sessions/:id/interactive', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
// Body is optional (auto-reattach callers send none) — same idiom as /interactive-respawn.
|
||
const bodyResult = req.body
|
||
? InteractiveStartSchema.safeParse(req.body)
|
||
: { success: true as const, data: {} as { clearBreaker?: boolean } };
|
||
if (!bodyResult.success) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
|
||
}
|
||
const { clearBreaker } = bodyResult.data;
|
||
const session = findSessionOrFail(ctx, id, req);
|
||
|
||
if (session.isBusy()) {
|
||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||
}
|
||
|
||
try {
|
||
// Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user)
|
||
// Ralph tracker is not supported for opencode / codex / gemini / antigravity sessions
|
||
if (
|
||
session.mode !== 'opencode' &&
|
||
session.mode !== 'codex' &&
|
||
session.mode !== 'gemini' &&
|
||
session.mode !== 'antigravity' &&
|
||
ctx.store.getConfig().ralphEnabled &&
|
||
!session.ralphTracker.autoEnableDisabled
|
||
) {
|
||
autoConfigureRalph(session, session.workingDir, ctx);
|
||
if (!session.ralphTracker.enabled) {
|
||
session.ralphTracker.enable();
|
||
}
|
||
}
|
||
|
||
// COD-118: ONLY an explicit user-initiated restart (body {clearBreaker:true})
|
||
// clears a tripped PTY-exit circuit breaker. This endpoint is ALSO the frontend's
|
||
// automatic re-attach path (selectSession auto-POSTs it for any pid===null
|
||
// session), so an unconditional reset here would re-arm the exact crash loop
|
||
// the breaker exists to stop — auto-reattach sends no body and must not clear.
|
||
if (clearBreaker) {
|
||
session.resetRespawnBreaker();
|
||
}
|
||
// Re-attach listener wiring if a prior PTY exit detached it: the wiring exit
|
||
// handler removes ALL session listeners (incl. respawnBreakerTripped), and only
|
||
// session-create/boot-recovery paths ran setupSessionListeners before this fix —
|
||
// without this, a re-attached session's SSE/terminal/trip events go unobserved.
|
||
// setupSessionListeners is idempotent (no-op while refs are still attached).
|
||
await ctx.setupSessionListeners(session);
|
||
await session.startInteractive();
|
||
getLifecycleLog().log({
|
||
event: 'started',
|
||
sessionId: id,
|
||
name: session.name,
|
||
mode: session.mode,
|
||
});
|
||
ctx.broadcast(SseEvent.SessionInteractive, { id });
|
||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||
|
||
return {};
|
||
} catch (err) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||
}
|
||
});
|
||
|
||
// ========== Start Shell Mode ==========
|
||
|
||
app.post('/api/sessions/:id/shell', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const session = findSessionOrFail(ctx, id, req);
|
||
|
||
if (session.isBusy()) {
|
||
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
|
||
}
|
||
|
||
try {
|
||
// Re-attach listener wiring if a prior PTY exit detached it (see /interactive).
|
||
await ctx.setupSessionListeners(session);
|
||
await session.startShell();
|
||
getLifecycleLog().log({
|
||
event: 'started',
|
||
sessionId: id,
|
||
name: session.name,
|
||
mode: 'shell',
|
||
});
|
||
ctx.broadcast(SseEvent.SessionInteractive, { id, mode: 'shell' });
|
||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||
return {};
|
||
} catch (err) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||
}
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Terminal I/O (input, resize, buffer)
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
// ========== Send Input ==========
|
||
|
||
app.post('/api/sessions/:id/input', async (req, reply) => {
|
||
const { id } = req.params as { id: string };
|
||
const { input, useMux, seq, clientId, wait, waitTimeout } = parseBody(SessionInputWithLimitSchema, req.body);
|
||
const session = findSessionOrFail(ctx, id, req);
|
||
|
||
const inputStr = String(input);
|
||
if (inputStr.length > MAX_INPUT_LENGTH) {
|
||
return createErrorResponse(
|
||
ApiErrorCode.INVALID_INPUT,
|
||
`Input exceeds maximum length (${MAX_INPUT_LENGTH} bytes)`
|
||
);
|
||
}
|
||
|
||
// Send-and-wait (agent orchestration). This has to be ONE endpoint rather than a
|
||
// POST followed by GET .../wait: between the write and the session flipping to
|
||
// `working` there is a window in which a separate wait sees the session still
|
||
// idle and returns instantly, reporting the PREVIOUS turn as this turn's answer.
|
||
// Registering the waiter before the write closes that window.
|
||
const wantsWait =
|
||
wait === true || (typeof wait === 'string' && wait.trim().length > 0) || (Array.isArray(wait) && wait.length > 0);
|
||
let until: readonly WaitSignal[] = [];
|
||
if (wantsWait) {
|
||
const resolved = resolveWaitSignals(wait === true ? undefined : wait, { mode: session.mode });
|
||
if (resolved.error) return createErrorResponse(ApiErrorCode.INVALID_INPUT, resolved.error);
|
||
until = resolved.until;
|
||
}
|
||
|
||
// Reliable delivery (POST fallback when the WebSocket is down): a 2xx IS the
|
||
// client's ACK, so a tagged duplicate redelivery must still return 200 but
|
||
// skip the write. Untagged requests (curl/legacy) always apply.
|
||
const tagged = typeof clientId === 'string' && typeof seq === 'number';
|
||
const duplicate = tagged && !session.shouldApplyInput(clientId as string, seq as number);
|
||
if (duplicate && !wantsWait) {
|
||
return {};
|
||
}
|
||
|
||
// Only a waiting request pays for the tmux probe: the browser's plain input path
|
||
// (thousands of calls per session) must stay exec-free.
|
||
const workerDead = wantsWait && workerIsDead(ctx.mux, session);
|
||
|
||
const timeoutMs = clampWaitMs(waitTimeout ?? undefined);
|
||
// Same slot leak as the GET routes: a client that gives up mid-wait would
|
||
// otherwise hold a waiter for the full timeout. Response-side, always — see
|
||
// abortOnClientHangUp: on THIS route a request-side listener fires the moment the
|
||
// JSON body finishes streaming and aborts every send-and-wait before it starts.
|
||
const abort = abortOnClientHangUp(reply);
|
||
let waitPromise: Promise<SignalWaitResult> | null = null;
|
||
if (wantsWait) {
|
||
try {
|
||
waitPromise = sessionWaits.waitForSignal(id, {
|
||
until,
|
||
timeoutMs,
|
||
owner: ownerFor(req),
|
||
abortSignal: abort.signal,
|
||
// A FRESH delivery must not be satisfied by the state the session is already
|
||
// in: it is idle right now, which is precisely why we are typing at it.
|
||
// A DUPLICATE has no new turn coming, so it answers from the current state
|
||
// instead of blocking for a transition that already happened.
|
||
requireTransition: !duplicate,
|
||
currentSignal: duplicate ? currentSignalFor(session, workerDead) : undefined,
|
||
});
|
||
} catch (err) {
|
||
if (err instanceof WaitCapacityError) {
|
||
// Nothing has been written yet, but `shouldApplyInput` already consumed the
|
||
// seq. Give it back or the caller's retry is rejected as a duplicate and the
|
||
// input is lost by the very mechanism meant to make delivery reliable.
|
||
if (tagged && !duplicate) session.forgetInputSeq(clientId as string, seq as number);
|
||
return waitCapacityResponse(err);
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
const stopDeathWatch = wantsWait ? watchForDeadWorker(ctx.mux, session, id) : () => {};
|
||
|
||
// Write input to PTY. Direct write is synchronous; writeViaMux
|
||
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
|
||
//
|
||
// Because the response has already been sent by then, a failure there is the
|
||
// one case the caller can never learn about — so the dedup bookkeeping is
|
||
// rolled back. Otherwise the seq stays recorded as applied and a retry, the
|
||
// very mechanism reliable delivery exists for, is rejected as a duplicate.
|
||
const undoOnFailure = () => {
|
||
if (tagged) session.forgetInputSeq(clientId as string, seq as number);
|
||
};
|
||
|
||
// Whether the bytes actually reached a write path. Only meaningful on the wait
|
||
// path (the fire-and-forget branches return before the response is built), and
|
||
// reported there instead of the old `!duplicate`: a PTY that has exited fails
|
||
// BOTH writes, and telling the caller "delivered, but it timed out" points it at
|
||
// the wrong recovery — wait longer, when the truth is "restart the worker".
|
||
let delivered = false;
|
||
|
||
if (duplicate) {
|
||
// Redelivery of an already-applied input: skip the write, but still honor the
|
||
// wait, since the caller's question ("tell me when this settles") is unanswered.
|
||
} else if (useMux && waitPromise) {
|
||
// The response is already staying open for the wait, so the tmux write can be
|
||
// awaited here. This is the ONE path where a writeViaMux failure is observable.
|
||
const ok = await session.writeViaMux(inputStr).catch(() => false);
|
||
if (ok) {
|
||
delivered = true;
|
||
} else {
|
||
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
|
||
delivered = session.write(inputStr);
|
||
if (!delivered) undoOnFailure();
|
||
}
|
||
} else if (useMux) {
|
||
// Fire-and-forget: don't block the HTTP response on a tmux child process.
|
||
// Fallback to a direct write on failure. Unchanged from before send-and-wait.
|
||
session
|
||
.writeViaMux(inputStr)
|
||
.then((ok) => {
|
||
if (ok) return;
|
||
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
|
||
if (!session.write(inputStr)) undoOnFailure();
|
||
})
|
||
.catch(() => {
|
||
if (!session.write(inputStr)) undoOnFailure();
|
||
});
|
||
} else {
|
||
// Same rollback. NOT an error response, deliberately: a session can
|
||
// legitimately have no PTY yet (created but not started), and callers have
|
||
// always been able to write to one without a 4xx.
|
||
delivered = session.write(inputStr);
|
||
if (!delivered && tagged) {
|
||
session.forgetInputSeq(clientId as string, seq as number);
|
||
}
|
||
}
|
||
|
||
if (!waitPromise) return {};
|
||
|
||
try {
|
||
// `send-keys` SUCCEEDS against a dead pane — tmux is happy to write into a corpse
|
||
// — so a truthful `delivered` cannot come from the write's return value alone.
|
||
// This is the case the field exists for: "delivered, but it timed out" tells an
|
||
// agent to wait longer when the truth is "restart the worker".
|
||
if (delivered && workerDead) {
|
||
delivered = false;
|
||
// The bytes went nowhere, so the seq must not be recorded as applied or the
|
||
// caller's retry against a restarted worker is refused as a duplicate.
|
||
if (!duplicate) undoOnFailure();
|
||
}
|
||
|
||
// Nothing was written and nothing will be: no turn is coming, so blocking for the
|
||
// full timeout would only delay the caller's real recovery by up to ten minutes.
|
||
// Releasing the waiter also hands its slot back immediately.
|
||
const selfReleased = !delivered && !duplicate;
|
||
if (selfReleased) abort.abort();
|
||
|
||
const result = await waitPromise;
|
||
return {
|
||
success: true,
|
||
data: {
|
||
delivered,
|
||
duplicate,
|
||
status: session.status,
|
||
limitPaused: session.isLimitPaused,
|
||
// Identical `wait` object to the two GET endpoints, so one client helper
|
||
// reads all three, `timeoutMs` (post-clamp) included.
|
||
//
|
||
// `aborted` is the CLIENT-facing "you hung up, nobody is reading this", and
|
||
// by that definition it is unobservable — which is exactly what the API
|
||
// reference promises. The abort above is the server releasing its own waiter
|
||
// on a delivery that failed, and the client IS reading this response, so
|
||
// reporting `aborted: true` there would break that promise and hand an agent
|
||
// a second, contradictory reason for the same outcome. `delivered: false`
|
||
// already says what happened; `ended` says the wait was released early.
|
||
wait: { ...result, aborted: selfReleased ? false : result.aborted, until: [...until] },
|
||
},
|
||
};
|
||
} finally {
|
||
stopDeathWatch();
|
||
}
|
||
});
|
||
|
||
// ========== Wait For A Signal (agent orchestration) ==========
|
||
//
|
||
// A bounded long-poll: block until the session hits one of `until`, then answer.
|
||
// This exists because SSE is the only "tell me when" channel Codeman has, and an
|
||
// agent driving the API from a shell tool cannot hold a stream and parse events
|
||
// inline. See docs/agent-control-plan.md.
|
||
//
|
||
// A TIMEOUT IS A 200, not an error: callers are expected to loop over short waits
|
||
// (proxies such as `tailscale serve` cut idle connections), and turning every poll
|
||
// boundary into a 4xx would make that loop indistinguishable from a real failure.
|
||
|
||
app.get('/api/sessions/:id/wait', async (req, reply) => {
|
||
const { id } = req.params as { id: string };
|
||
const query = parseWaitQuery(SessionWaitQuerySchema, req.query, 'wait');
|
||
const session = findSessionOrFail(ctx, id, req);
|
||
|
||
// An agent polls this URL in a loop with identical parameters. Any intermediary
|
||
// applying heuristic freshness to the 200 would serve the stored `timedOut:true`
|
||
// body to the next iteration instantly, turning the loop into a busy spin that
|
||
// never observes the signal.
|
||
reply.header('Cache-Control', 'no-store');
|
||
|
||
// Shared with the `wait` field on POST .../input: unknown token is a 400,
|
||
// hook-only signals are rejected explicitly but dropped from the default.
|
||
const { until, error } = resolveWaitSignals(query.until, { mode: session.mode });
|
||
if (error) return createErrorResponse(ApiErrorCode.INVALID_INPUT, error);
|
||
|
||
// The value actually applied after clamping, echoed below: a caller that asked
|
||
// for 30 minutes and silently got 10 could not otherwise tell a poll boundary
|
||
// from a wedged worker, and would kill a session that was working fine.
|
||
const timeoutMs = clampWaitMs(query.timeout);
|
||
|
||
// Free the waiter when the caller hangs up; the response can no longer be sent by
|
||
// then, so freeing the slot is the entire purpose.
|
||
const abort = abortOnClientHangUp(reply);
|
||
// A worker that dies while this request is parked emits nothing at all (the tmux
|
||
// attach client survives it), so a wait would otherwise run to its full timeout.
|
||
const stopDeathWatch = watchForDeadWorker(ctx.mux, session, id);
|
||
|
||
try {
|
||
const result = await sessionWaits.waitForSignal(id, {
|
||
until,
|
||
timeoutMs,
|
||
owner: ownerFor(req),
|
||
abortSignal: abort.signal,
|
||
requireTransition: query.fresh === '1' || query.fresh === 'true',
|
||
// Read BEFORE awaiting: this is the state the caller is asking about.
|
||
currentSignal: currentSignalFor(session, workerIsDead(ctx.mux, session)),
|
||
});
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
sessionId: id,
|
||
// Post-wait status, so a caller that timed out still learns where things stand.
|
||
status: session.status,
|
||
// A session paused on a usage limit emits nothing until its reset, so a
|
||
// timeout here is expected rather than a stall worth retrying hard.
|
||
limitPaused: session.isLimitPaused,
|
||
// One shape across all three endpoints, so a single `is_done(resp)` helper
|
||
// works against any of them. `result.timeoutMs` is the value actually
|
||
// applied after clamping, which is what makes the clamp observable.
|
||
wait: { ...result, until: [...until] },
|
||
},
|
||
};
|
||
} catch (err) {
|
||
if (err instanceof WaitCapacityError) return waitCapacityResponse(err);
|
||
throw err;
|
||
} finally {
|
||
stopDeathWatch();
|
||
}
|
||
});
|
||
|
||
// ========== Wait For Output (agent orchestration) ==========
|
||
//
|
||
// The companion to /wait: block until a literal string appears in this session's
|
||
// output. Same 200-on-timeout contract. Fed by the `terminal` listener in
|
||
// session-listener-wiring.ts, so what this scans is byte-for-byte what the pane
|
||
// printed, ANSI stripped.
|
||
//
|
||
// ⚠️ A tmux repaint replays text already on screen, so `from=now` can match
|
||
// something printed before the request. Callers need a marker unique per call.
|
||
|
||
app.get('/api/sessions/:id/wait-output', async (req, reply) => {
|
||
const { id } = req.params as { id: string };
|
||
|
||
// Reject `regex` loudly instead of ignoring it. Matching is deliberately literal
|
||
// (no ReDoS surface on a caller-supplied pattern over a live stream), and an agent
|
||
// that assumed otherwise would silently wait on the wrong thing.
|
||
if (req.query && typeof req.query === 'object' && 'regex' in req.query) {
|
||
return createErrorResponse(
|
||
ApiErrorCode.INVALID_INPUT,
|
||
'regex is not supported; use match=<literal substring> (optionally with nocase=1)'
|
||
);
|
||
}
|
||
|
||
const query = parseWaitQuery(SessionWaitOutputQuerySchema, req.query, 'wait-output');
|
||
const session = findSessionOrFail(ctx, id, req);
|
||
|
||
// Same reason as /wait: this URL is polled in a loop with identical parameters.
|
||
reply.header('Cache-Control', 'no-store');
|
||
|
||
const timeoutMs = clampWaitMs(query.timeout);
|
||
const abort = abortOnClientHangUp(reply);
|
||
const owner = ownerFor(req);
|
||
// Output waiters are the ones a dead worker strands hardest: the feed simply stops.
|
||
const stopDeathWatch = watchForDeadWorker(ctx.mux, session, id);
|
||
|
||
try {
|
||
// Check the cap BEFORE touching the buffer. `session.terminalBuffer` is
|
||
// `BufferAccumulator.value`, which joins the WHOLE accumulator (up to 32MB)
|
||
// before the slice below takes its tail — so a request that is going to be
|
||
// rejected anyway must not pay for a full materialization first, or the cap
|
||
// provides no backpressure at all against a `from=buffer` loop.
|
||
sessionWaits.assertCapacity(id, owner);
|
||
|
||
// `from=buffer` scans what already scrolled past before blocking. Bounded to a
|
||
// tail: the buffer runs to 32MB and this is a per-request ANSI strip.
|
||
let initialText: string | undefined;
|
||
if (query.from === 'buffer') {
|
||
const buffer = session.terminalBuffer;
|
||
initialText =
|
||
buffer.length > MAX_BUFFER_SCAN_BYTES ? buffer.slice(buffer.length - MAX_BUFFER_SCAN_BYTES) : buffer;
|
||
}
|
||
|
||
const result = await sessionWaits.waitForOutput(id, {
|
||
match: query.match,
|
||
nocase: query.nocase === '1' || query.nocase === 'true',
|
||
timeoutMs,
|
||
owner,
|
||
abortSignal: abort.signal,
|
||
initialText,
|
||
});
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
sessionId: id,
|
||
status: session.status,
|
||
limitPaused: session.isLimitPaused,
|
||
// Same envelope as /wait; this one carries `matched`/`snippet`/`match`
|
||
// where the signal wait carries `signal`/`until`.
|
||
wait: { ...result, match: query.match },
|
||
},
|
||
};
|
||
} catch (err) {
|
||
if (err instanceof WaitCapacityError) return waitCapacityResponse(err);
|
||
throw err;
|
||
} finally {
|
||
stopDeathWatch();
|
||
}
|
||
});
|
||
|
||
// ========== Send Named Key (tmux send-keys -H) ==========
|
||
// Sends raw hex bytes to tmux pane for keys like Shift+Enter / Ctrl+Enter.
|
||
// Uses send-keys -H (hex) to inject 0x0a (line feed) which Claude Code's
|
||
// Ink input recognizes as "insert newline" vs 0x0d (carriage return = submit).
|
||
|
||
app.post('/api/sessions/:id/send-key', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const body = req.body as Record<string, unknown>;
|
||
const key = typeof body?.key === 'string' ? body.key : '';
|
||
|
||
// Map key names to hex byte sequences
|
||
const KEY_HEX_MAP: Record<string, string[]> = {
|
||
'S-Enter': ['0a'], // \n (line feed)
|
||
'C-Enter': ['0a'], // \n (line feed)
|
||
};
|
||
const hex = KEY_HEX_MAP[key];
|
||
if (!hex) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Key not allowed: ${key}`);
|
||
}
|
||
|
||
const session = findSessionOrFail(ctx, id, req);
|
||
const muxName = session.muxName;
|
||
if (!muxName) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No tmux session');
|
||
}
|
||
|
||
try {
|
||
// Route through the dedicated Codeman socket — bare `tmux` would target the
|
||
// user's default server and never find this session (same #80 regression class).
|
||
await new Promise<void>((resolve, reject) => {
|
||
execFile(
|
||
'tmux',
|
||
['-L', ctx.mux.muxSocket, 'send-keys', '-H', '-t', muxName, ...hex],
|
||
{ timeout: 5000 },
|
||
(err) => {
|
||
if (err) reject(err);
|
||
else resolve();
|
||
}
|
||
);
|
||
});
|
||
} catch (err) {
|
||
console.error('[Server] send-key failed:', err);
|
||
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'tmux send-keys failed');
|
||
}
|
||
return {};
|
||
});
|
||
|
||
// ========== Resize Terminal ==========
|
||
|
||
app.post('/api/sessions/:id/resize', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const { cols, rows, viewportType, force } = parseBody(ResizeSchema, req.body);
|
||
const session = findSessionOrFail(ctx, id, req);
|
||
|
||
session.resize(cols, rows, { viewportType, force });
|
||
return {};
|
||
});
|
||
|
||
// ========== Get Last Response (from transcript JSONL) ==========
|
||
|
||
// How far apart a ~/.claude/history.jsonl entry and a pane's Enter may be and
|
||
// still be the same submission. Claude appends to history as it accepts the
|
||
// prompt, so the true gap is milliseconds — this is slack for a loaded box,
|
||
// not a search radius.
|
||
const CLAUDE_SUBMIT_MATCH_MS = 10_000;
|
||
// history.jsonl grows forever; only the tail can hold entries near a submit.
|
||
const CLAUDE_HISTORY_TAIL_BYTES = 256 * 1024;
|
||
|
||
// Resolves the Claude conversation id THIS pane is currently on by matching
|
||
// ~/.claude/history.jsonl (which logs every submitted prompt as
|
||
// {project, sessionId, timestamp}) against the pane's last Enter. After
|
||
// `/clear` Claude keeps writing to a new <uuid>.jsonl, and history.jsonl is
|
||
// the only source-of-truth update that does not rely on project-local hooks
|
||
// (we intentionally don't install hooks in arbitrary user repos, see the
|
||
// POST /api/sessions comment).
|
||
//
|
||
// The pane's own Enter is what makes an entry OURS. `project` alone is not:
|
||
// a cwd is shared by every other Codeman tab on it, by tabs long since
|
||
// closed, and by any plain `claude` the user runs in their own terminal —
|
||
// adopting the newest entry for the cwd pinned the viewer to whichever of
|
||
// those conversations was typed in last, so the eye showed a stranger's
|
||
// transcript. With no correlated entry we keep the id we have; a viewer one
|
||
// turn behind beats a viewer showing someone else's conversation.
|
||
const claudeHistoryPinCache = new LRUMap<string, { submitAt: number; claudeSessionId: string }>({ maxSize: 1024 });
|
||
async function resolveActiveClaudeSessionIdFromHistory(
|
||
session: Session,
|
||
projectsDir: string
|
||
): Promise<string | null> {
|
||
const submitAt = session.lastSubmitAt;
|
||
if (!submitAt) return null; // never typed through Codeman — nothing to credit
|
||
const cached = claudeHistoryPinCache.get(session.id);
|
||
if (cached && cached.submitAt === submitAt) return cached.claudeSessionId;
|
||
|
||
// Ids another live pane is already pinned to can never be ours, and every
|
||
// pane sharing this cwd competes for the entry we are about to claim —
|
||
// including non-Claude panes, since a shell pane can run `claude` too.
|
||
const otherClaudeIds = new Set<string>();
|
||
const otherSubmits: number[] = [];
|
||
for (const s of ctx.sessions.values()) {
|
||
if (s.id === session.id || s.workingDir !== session.workingDir) continue;
|
||
if (s.claudeSessionId) otherClaudeIds.add(s.claudeSessionId);
|
||
if (s.lastSubmitAt) otherSubmits.push(s.lastSubmitAt);
|
||
}
|
||
|
||
const historyPath = join(homedir(), '.claude', 'history.jsonl');
|
||
const stat = await fs.stat(historyPath).catch(() => null);
|
||
if (!stat || stat.size === 0) return null;
|
||
const tail = await readFileTail(historyPath, Buffer.alloc(CLAUDE_HISTORY_TAIL_BYTES), stat.size);
|
||
if (!tail) return null;
|
||
|
||
let best: { sessionId: string; dist: number } | undefined;
|
||
for (const line of tail.split('\n')) {
|
||
if (!line) continue;
|
||
let entry: { project?: string; sessionId?: string; timestamp?: number };
|
||
try {
|
||
entry = JSON.parse(line) as typeof entry;
|
||
} catch {
|
||
continue; // the first tail line is usually cut mid-JSON
|
||
}
|
||
const { sessionId, timestamp } = entry;
|
||
if (entry.project !== session.workingDir) continue;
|
||
if (typeof sessionId !== 'string' || !sessionId) continue;
|
||
if (typeof timestamp !== 'number') continue;
|
||
if (otherClaudeIds.has(sessionId)) continue;
|
||
const dist = Math.abs(timestamp - submitAt);
|
||
if (dist > CLAUDE_SUBMIT_MATCH_MS) continue;
|
||
if (otherSubmits.some((other) => Math.abs(timestamp - other) < dist)) continue; // another pane is closer
|
||
if (!best || dist <= best.dist) best = { sessionId, dist }; // ties: the newer entry wins
|
||
}
|
||
if (!best) return null;
|
||
|
||
// Sanity: the conversation we switch to must exist on disk and must not be
|
||
// staler than the one we are leaving. A `/clear` successor never is.
|
||
const currentSessionId = session.claudeSessionId || session.id;
|
||
if (best.sessionId !== currentSessionId) {
|
||
const projectDirs = await fs.readdir(projectsDir).catch(() => null);
|
||
if (!projectDirs) return null;
|
||
let candidateMtime = 0;
|
||
let currentMtime = 0;
|
||
for (const projDir of projectDirs) {
|
||
const candidateStat = await fs.stat(join(projectsDir, projDir, `${best.sessionId}.jsonl`)).catch(() => null);
|
||
if (candidateStat && candidateStat.mtimeMs > candidateMtime) candidateMtime = candidateStat.mtimeMs;
|
||
const currentStat = await fs.stat(join(projectsDir, projDir, `${currentSessionId}.jsonl`)).catch(() => null);
|
||
if (currentStat && currentStat.mtimeMs > currentMtime) currentMtime = currentStat.mtimeMs;
|
||
}
|
||
if (candidateMtime === 0) return null; // transcript not written yet — retry next poll
|
||
if (currentMtime > 0 && candidateMtime < currentMtime) return null;
|
||
}
|
||
|
||
claudeHistoryPinCache.set(session.id, { submitAt, claudeSessionId: best.sessionId });
|
||
return best.sessionId;
|
||
}
|
||
|
||
interface ClaudeResponseMessage {
|
||
role: 'user' | 'assistant';
|
||
text: string;
|
||
timestamp?: string;
|
||
}
|
||
|
||
interface ClaudeTranscriptEntry {
|
||
type?: string;
|
||
timestamp?: string;
|
||
isMeta?: boolean;
|
||
isSidechain?: boolean;
|
||
isCompactSummary?: boolean;
|
||
message?: { content?: unknown };
|
||
}
|
||
|
||
function extractClaudeText(content: unknown, separator: string): string {
|
||
if (typeof content === 'string') return content;
|
||
if (!Array.isArray(content)) return '';
|
||
return content
|
||
.filter(
|
||
(block): block is { type: string; text: string } =>
|
||
!!block &&
|
||
typeof block === 'object' &&
|
||
(block as { type?: string }).type === 'text' &&
|
||
typeof (block as { text?: string }).text === 'string'
|
||
)
|
||
.map((block) => block.text)
|
||
.join(separator);
|
||
}
|
||
|
||
function isClaudeSyntheticUserMessage(entry: ClaudeTranscriptEntry, text: string): boolean {
|
||
if (entry.isMeta || entry.isCompactSummary) return true;
|
||
return /^(?:<local-command|<command-name>|<task-notification>|<system-reminder>|<teammate-message\b|Another Claude session sent a message:|Base directory for this skill:)/i.test(
|
||
text
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Claude writes one logical turn as many JSONL rows: text, thinking and tool
|
||
* blocks share message ids, while tool results are represented as user rows.
|
||
* Build viewer cards from real user boundaries instead of treating every row
|
||
* as a separate chat message.
|
||
*/
|
||
function parseClaudeResponseTranscript(
|
||
content: string,
|
||
full: boolean
|
||
): { text: string; timestamp: string; messages?: ClaudeResponseMessage[] } {
|
||
let lastText = '';
|
||
let lastTimestamp = '';
|
||
const messages: ClaudeResponseMessage[] = [];
|
||
let currentUserFragments = new Set<string>();
|
||
let currentAssistantFragments = new Set<string>();
|
||
|
||
for (const line of content.split('\n')) {
|
||
if (!line) continue;
|
||
let entry: ClaudeTranscriptEntry;
|
||
try {
|
||
entry = JSON.parse(line) as ClaudeTranscriptEntry;
|
||
} catch {
|
||
continue;
|
||
}
|
||
// Sidechains belong to agents/forks, not the main conversation. Meta user
|
||
// rows include repeated image dimensions and other UI-generated context.
|
||
if (entry.isSidechain) continue;
|
||
|
||
if (entry.type === 'user') {
|
||
const text = extractClaudeText(entry.message?.content, '\n').trim();
|
||
// A tool_result block has no text block and naturally drops out here.
|
||
if (!text || isClaudeSyntheticUserMessage(entry, text)) continue;
|
||
if (!full) continue;
|
||
|
||
const previous = messages.at(-1);
|
||
if (previous?.role === 'user') {
|
||
// Claude can replay the initial user row while restoring a transcript.
|
||
// Only collapse duplicates within the same unanswered user turn; the
|
||
// same prompt after an assistant response remains a legitimate turn.
|
||
if (currentUserFragments.has(text)) continue;
|
||
previous.text += `\n\n${text}`;
|
||
currentUserFragments.add(text);
|
||
} else {
|
||
messages.push({ role: 'user', text, timestamp: entry.timestamp });
|
||
currentUserFragments = new Set([text]);
|
||
}
|
||
currentAssistantFragments.clear();
|
||
continue;
|
||
}
|
||
|
||
if (entry.type !== 'assistant') continue;
|
||
const text = extractClaudeText(entry.message?.content, '\n\n').trim();
|
||
if (!text) continue;
|
||
lastText = text;
|
||
lastTimestamp = entry.timestamp || '';
|
||
if (!full) continue;
|
||
|
||
const previous = messages.at(-1);
|
||
if (previous?.role === 'assistant') {
|
||
// Replayed snapshots sometimes repeat an identical text block. Distinct
|
||
// progress/final blocks are kept, but remain inside one Claude card.
|
||
if (currentAssistantFragments.has(text)) continue;
|
||
previous.text += `\n\n${text}`;
|
||
previous.timestamp = entry.timestamp || previous.timestamp;
|
||
currentAssistantFragments.add(text);
|
||
} else {
|
||
messages.push({ role: 'assistant', text, timestamp: entry.timestamp });
|
||
currentAssistantFragments = new Set([text]);
|
||
}
|
||
currentUserFragments.clear();
|
||
}
|
||
|
||
return full ? { text: lastText, timestamp: lastTimestamp, messages } : { text: lastText, timestamp: lastTimestamp };
|
||
}
|
||
|
||
/** Locate a top-level Claude transcript, including recovered tmux sessions. */
|
||
async function findClaudeTranscript(
|
||
projectsDir: string,
|
||
conversationId: string,
|
||
codemanSessionId: string
|
||
): Promise<{ sessionId: string; path: string } | null> {
|
||
let projectDirs: import('node:fs').Dirent[];
|
||
try {
|
||
projectDirs = await fs.readdir(projectsDir, { withFileTypes: true });
|
||
} catch {
|
||
return null;
|
||
}
|
||
|
||
const safeIds = [...new Set([conversationId, codemanSessionId])].filter((value) => /^[a-zA-Z0-9._-]+$/.test(value));
|
||
for (const candidateId of safeIds) {
|
||
for (const projectDir of projectDirs) {
|
||
if (!projectDir.isDirectory()) continue;
|
||
const jsonlPath = join(projectsDir, projectDir.name, `${candidateId}.jsonl`);
|
||
try {
|
||
const stat = await fs.stat(jsonlPath);
|
||
if (stat.isFile()) return { sessionId: candidateId, path: jsonlPath };
|
||
} catch {
|
||
/* continue */
|
||
}
|
||
}
|
||
}
|
||
|
||
// If mux-sessions.json was lost or stale, reconcileSessions() historically
|
||
// recovered `codeman-40568a29` as `restored-40568a29` and used the server cwd.
|
||
// The tmux name still carries the first eight UUID characters, which safely
|
||
// reconnects the viewer when exactly one matching top-level transcript exists.
|
||
const restoredMatch = /^restored-([a-f0-9]{8,})$/i.exec(codemanSessionId);
|
||
if (!restoredMatch) return null;
|
||
const fragment = restoredMatch[1].toLowerCase();
|
||
const candidates: Array<{ sessionId: string; path: string; mtimeMs: number }> = [];
|
||
const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
|
||
|
||
for (const projectDir of projectDirs) {
|
||
if (!projectDir.isDirectory()) continue;
|
||
const dirPath = join(projectsDir, projectDir.name);
|
||
let files: import('node:fs').Dirent[];
|
||
try {
|
||
files = await fs.readdir(dirPath, { withFileTypes: true });
|
||
} catch {
|
||
continue;
|
||
}
|
||
for (const file of files) {
|
||
if (!file.isFile() || !file.name.endsWith('.jsonl')) continue;
|
||
const candidateId = file.name.slice(0, -'.jsonl'.length);
|
||
if (!candidateId.toLowerCase().startsWith(fragment) || !uuidPattern.test(candidateId)) continue;
|
||
const path = join(dirPath, file.name);
|
||
const stat = await fs.stat(path).catch(() => null);
|
||
if (stat) candidates.push({ sessionId: candidateId, path, mtimeMs: stat.mtimeMs });
|
||
}
|
||
}
|
||
|
||
const candidateIds = new Set(candidates.map((candidate) => candidate.sessionId));
|
||
if (candidateIds.size !== 1) return null;
|
||
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||
return candidates[0] ?? null;
|
||
}
|
||
|
||
app.get('/api/sessions/:id/last-response', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const session = findSessionOrFail(ctx, id, req);
|
||
|
||
// Codex sessions don't write to ~/.claude/projects — their transcripts
|
||
// live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the
|
||
// response-viewer works for Codex panes too.
|
||
if (session.mode === 'codex') {
|
||
const codexQuery = req.query as { context?: string };
|
||
return await readCodexLastResponse(session, codexQuery.context === 'full');
|
||
}
|
||
|
||
// Scan ~/.claude/projects/*/ for the transcript file
|
||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||
|
||
// Adopt the current conversation id if the user ran `/clear` — Claude CLI's
|
||
// interactive PTY emits no JSON on stdout, so without this lookup the
|
||
// stored id stays pinned to the pre-/clear transcript.
|
||
const activeId = await resolveActiveClaudeSessionIdFromHistory(session, projectsDir);
|
||
if (activeId && activeId !== session.claudeSessionId) {
|
||
session.adoptClaudeSessionId(activeId);
|
||
// Flush the Enter that vouched for this adoption to state.json. A `/clear`
|
||
// emits no completion event, so without this the anchor could still be
|
||
// unpersisted when the server restarts — and recovery would fall back to
|
||
// the launch conversation.
|
||
ctx.persistSessionState(session);
|
||
// Docker sessions: keep the case's resume seed following the live conversation.
|
||
if (session.docker) {
|
||
void persistDockerCaseClaudeSessionId(CODEMAN_CONFIG_DIR, session.docker.containerName, activeId).catch(
|
||
() => {}
|
||
);
|
||
}
|
||
}
|
||
|
||
const query = req.query as { context?: string };
|
||
const claudeSessionId = session.claudeSessionId || session.id;
|
||
const transcript = await findClaudeTranscript(projectsDir, claudeSessionId, session.id);
|
||
if (!transcript) {
|
||
return query.context === 'full' ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
|
||
}
|
||
|
||
if (transcript.sessionId !== session.claudeSessionId && transcript.sessionId !== session.id) {
|
||
session.adoptClaudeSessionId(transcript.sessionId);
|
||
if (session.docker) {
|
||
void persistDockerCaseClaudeSessionId(
|
||
CODEMAN_CONFIG_DIR,
|
||
session.docker.containerName,
|
||
transcript.sessionId
|
||
).catch(() => {});
|
||
}
|
||
}
|
||
|
||
try {
|
||
const content = await fs.readFile(transcript.path, 'utf8');
|
||
return parseClaudeResponseTranscript(content, query.context === 'full');
|
||
} catch {
|
||
return query.context === 'full' ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
|
||
}
|
||
});
|
||
|
||
function isCodexInjectedContext(text: string): boolean {
|
||
return (
|
||
/^# AGENTS\.md instructions\b/i.test(text) ||
|
||
/^<environment_context\b/i.test(text) ||
|
||
/^<turn_aborted\b/i.test(text) ||
|
||
/^<codex_internal_context\b/i.test(text) ||
|
||
/^<recommended_plugins\b/i.test(text) ||
|
||
/^<user_instructions\b/i.test(text) ||
|
||
/^# Options\b/i.test(text)
|
||
);
|
||
}
|
||
|
||
// ── Codex response-viewer support ───────────────────────────────────────────────────────
|
||
// Read the rollout's session_meta identity fields (plus turn_context cwd as
|
||
// a fallback when the huge session_meta line got truncated by the head read).
|
||
function readCodexRolloutMeta(head: string): { cwd?: string; originator?: string } {
|
||
let cwd: string | undefined;
|
||
let originator: string | undefined;
|
||
for (const line of head.split('\n')) {
|
||
if (!line) continue;
|
||
try {
|
||
const entry = JSON.parse(line) as {
|
||
type?: string;
|
||
payload?: { cwd?: string; originator?: string };
|
||
};
|
||
if (entry.type === 'session_meta') {
|
||
cwd ??= entry.payload?.cwd;
|
||
originator ??= entry.payload?.originator;
|
||
} else if (entry.type === 'turn_context') {
|
||
cwd ??= entry.payload?.cwd;
|
||
}
|
||
} catch {
|
||
// Malformed or truncated head line — keep scanning.
|
||
}
|
||
if (cwd && originator) break;
|
||
}
|
||
return { cwd, originator };
|
||
}
|
||
|
||
// The pane's last Enter (Session.lastSubmitAt) correlated against
|
||
// ~/.codex/history.jsonl, which logs every submitted user message as
|
||
// {session_id, ts}. This identifies the thread the pane is ACTUALLY on and
|
||
// is the only signal that survives /resume, /new and /fork typed inside the
|
||
// codex TUI itself. An entry is credited to this pane only when its Enter is
|
||
// the closest among all codex panes, so a menu keystroke in another pane
|
||
// can't steal the attribution.
|
||
const codexHistoryPinCache = new LRUMap<string, { submitAt: number; threadId: string }>({ maxSize: 1024 });
|
||
async function resolveCodexThreadFromHistory(
|
||
session: { id: string; lastSubmitAt?: number },
|
||
codexHome: string
|
||
): Promise<string | null> {
|
||
const submitAt = session.lastSubmitAt || 0;
|
||
if (!submitAt) return null;
|
||
const cached = codexHistoryPinCache.get(session.id);
|
||
if (cached && cached.submitAt === submitAt) return cached.threadId;
|
||
|
||
const histPath = join(codexHome, 'history.jsonl');
|
||
const st = await fs.stat(histPath).catch(() => null);
|
||
if (!st || st.size === 0) return null;
|
||
const tail = await readFileTail(histPath, Buffer.alloc(65536), st.size);
|
||
if (!tail) return null;
|
||
|
||
const WINDOW_MS = 15_000;
|
||
const otherSubmits: number[] = [];
|
||
for (const s of ctx.sessions.values()) {
|
||
if (s.id !== session.id && s.mode === 'codex' && s.lastSubmitAt) {
|
||
otherSubmits.push(s.lastSubmitAt);
|
||
}
|
||
}
|
||
|
||
let best: { threadId: string; dist: number } | undefined;
|
||
for (const line of tail.split('\n')) {
|
||
if (!line) continue;
|
||
let e: { session_id?: string; ts?: number };
|
||
try {
|
||
e = JSON.parse(line);
|
||
} catch {
|
||
continue; // first tail line may be cut mid-JSON
|
||
}
|
||
if (!e.session_id || typeof e.ts !== 'number') continue;
|
||
const tsMs = e.ts * 1000; // history timestamps are unix seconds
|
||
const dist = Math.abs(tsMs - submitAt);
|
||
if (dist > WINDOW_MS) continue;
|
||
if (otherSubmits.some((o) => Math.abs(tsMs - o) < dist)) continue; // another pane is closer
|
||
if (!best || dist < best.dist) best = { threadId: e.session_id, dist };
|
||
}
|
||
if (!best) return null;
|
||
codexHistoryPinCache.set(session.id, { submitAt, threadId: best.threadId });
|
||
return best.threadId;
|
||
}
|
||
|
||
// Locate THIS pane's rollout, in order of confidence:
|
||
// 0. history match — the thread the pane last submitted a message to
|
||
// (see resolveCodexThreadFromHistory); tracks the pane through
|
||
// /resume //new //fork typed inside the TUI.
|
||
// 1. originator match — Codeman spawns codex panes with
|
||
// CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codeman_<sessionId>, which codex
|
||
// writes into session_meta.originator of every rollout it creates
|
||
// (including new files after /new in the same pane; newest match wins).
|
||
// 2. resume-id match — resumed rollouts keep their ORIGINAL session_meta
|
||
// (codex appends without rewriting it), so originator matching can't
|
||
// see them; but the rollout uuid is in the filename and we know the id.
|
||
// 3. legacy cwd+mtime heuristic — panes started before this feature, or
|
||
// TUI-resumed threads before their first tracked submit. Case-blind
|
||
// cwd compare (codex records the launch-time case, /mnt paths vary)
|
||
// and rollouts claimed by OTHER codeman panes are excluded.
|
||
async function findActiveCodexFile(session: {
|
||
id: string;
|
||
workingDir: string;
|
||
lastSubmitAt?: number;
|
||
codexConfig?: { resumeSessionId?: string };
|
||
}): Promise<string | null> {
|
||
const codexHome = process.env.CODEX_HOME || join(process.env.HOME || '/tmp', '.codex');
|
||
const sessionsDir = join(codexHome, 'sessions');
|
||
|
||
const files: Array<{ path: string; mtimeMs: number }> = [];
|
||
const walk = async (dir: string): Promise<void> => {
|
||
let entries: import('node:fs').Dirent[];
|
||
try {
|
||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||
} catch {
|
||
return;
|
||
}
|
||
for (const entry of entries) {
|
||
const fullPath = join(dir, entry.name);
|
||
if (entry.isDirectory()) {
|
||
await walk(fullPath);
|
||
continue;
|
||
}
|
||
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
|
||
const st = await fs.stat(fullPath).catch(() => null);
|
||
if (!st || st.size < 100) continue;
|
||
files.push({ path: fullPath, mtimeMs: st.mtimeMs });
|
||
}
|
||
};
|
||
await walk(sessionsDir);
|
||
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||
|
||
const historyThreadId = await resolveCodexThreadFromHistory(session, codexHome);
|
||
if (historyThreadId) {
|
||
const hit = files.find((f) => basename(f.path).endsWith(`-${historyThreadId}.jsonl`));
|
||
if (hit) return hit.path;
|
||
}
|
||
|
||
const rawResumeId = session.codexConfig?.resumeSessionId;
|
||
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)
|
||
? rawResumeId
|
||
: undefined;
|
||
const idMatch = resumeId ? files.find((f) => basename(f.path).endsWith(`-${resumeId}.jsonl`)) : undefined;
|
||
|
||
// Scan newest-first for our originator; anything strictly older than the
|
||
// id match can never beat it, so the head reads stop there (mtime ties are
|
||
// still scanned — a /new rollout may land in the same clock tick). The
|
||
// 128 KiB head budget covers the session_meta line, which embeds full
|
||
// base_instructions (observed max ~22 KiB on codex 0.144).
|
||
const originator = `codeman_${session.id}`;
|
||
const wantCwd = session.workingDir.toLowerCase();
|
||
const headBuf = Buffer.alloc(131072);
|
||
let cwdFallback: { path: string; mtimeMs: number } | undefined;
|
||
for (const f of files) {
|
||
if (idMatch && f.mtimeMs < idMatch.mtimeMs) break;
|
||
const meta = await readCodexRolloutMetaCached(f.path, headBuf);
|
||
if (!meta) continue;
|
||
if (meta.originator === originator) return f.path; // newest-first → first hit wins
|
||
if (
|
||
!cwdFallback &&
|
||
!idMatch &&
|
||
meta.cwd?.toLowerCase() === wantCwd &&
|
||
// A rollout stamped by another codeman pane belongs to that pane.
|
||
!(meta.originator?.startsWith('codeman_') && meta.originator !== originator)
|
||
) {
|
||
cwdFallback = f;
|
||
}
|
||
}
|
||
|
||
return idMatch?.path ?? cwdFallback?.path ?? null;
|
||
}
|
||
|
||
// session_meta is written once when codex creates the rollout and never
|
||
// rewritten (verified: resume appends without touching it), so the parsed
|
||
// identity of a given path can be cached forever. This turns the per-request
|
||
// scan into stat calls plus head reads for new files only.
|
||
const codexRolloutMetaCache = new LRUMap<string, { cwd?: string; originator?: string }>({ maxSize: 4096 });
|
||
async function readCodexRolloutMetaCached(
|
||
filePath: string,
|
||
headBuf: Buffer
|
||
): Promise<{ cwd?: string; originator?: string } | null> {
|
||
const cached = codexRolloutMetaCache.get(filePath);
|
||
if (cached) return cached;
|
||
const head = await readFileHead(filePath, headBuf);
|
||
if (!head) return null;
|
||
const meta = readCodexRolloutMeta(head);
|
||
// Don't cache a still-incomplete head: a rollout being created may not
|
||
// have flushed session_meta/turn_context yet.
|
||
if (!meta.cwd && !meta.originator) return meta;
|
||
codexRolloutMetaCache.set(filePath, meta);
|
||
return meta;
|
||
}
|
||
|
||
function extractCodexBlockText(content: unknown, kinds: string[]): string {
|
||
if (typeof content === 'string') return content;
|
||
if (!Array.isArray(content)) return '';
|
||
return content
|
||
.filter(
|
||
(b): b is { type: string; text: string } =>
|
||
!!b &&
|
||
typeof b === 'object' &&
|
||
kinds.includes((b as { type?: string }).type || '') &&
|
||
typeof (b as { text?: string }).text === 'string'
|
||
)
|
||
.map((b) => b.text)
|
||
.join('\n\n');
|
||
}
|
||
|
||
// Single pass over a Codex rollout: track the last assistant message (for the
|
||
// default eye view) and, when `full`, the whole user/assistant thread.
|
||
//
|
||
// User turns come from event_msg/user_message when available: codex emits one
|
||
// per REAL user input, and injected context (AGENTS.md, environment_context,
|
||
// compaction summaries, …) never appears there — so no filtering heuristics.
|
||
// response_item user rows duplicate those inputs mixed with the injections;
|
||
// they are kept only as a fallback for old rollouts without event_msg rows.
|
||
async function readCodexLastResponse(
|
||
session: { id: string; workingDir: string; codexConfig?: { resumeSessionId?: string } },
|
||
full: boolean
|
||
): Promise<{
|
||
text: string;
|
||
timestamp: string;
|
||
messages?: Array<{ role: string; text: string; timestamp?: string }>;
|
||
}> {
|
||
const empty = full ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
|
||
const filePath = await findActiveCodexFile(session);
|
||
if (!filePath) return empty;
|
||
|
||
let content: string;
|
||
try {
|
||
content = await fs.readFile(filePath, 'utf8');
|
||
} catch {
|
||
return empty;
|
||
}
|
||
|
||
let lastText = '';
|
||
let lastTimestamp = '';
|
||
const messages: Array<{ role: string; text: string; timestamp?: string; legacyUser?: boolean }> = [];
|
||
// Multiset of event-sourced user texts: a real input appears BOTH as an
|
||
// event_msg and as a response_item row, so each event text cancels exactly
|
||
// one legacy twin. Legacy rows without an event twin (turns written by an
|
||
// older codex appending to the same rollout) survive — a file-wide boolean
|
||
// would wrongly drop them.
|
||
const eventUserTexts = new Map<string, number>();
|
||
|
||
for (const line of content.split('\n')) {
|
||
if (!line) continue;
|
||
let entry: {
|
||
timestamp?: string;
|
||
type?: string;
|
||
payload?: {
|
||
type?: string;
|
||
role?: string;
|
||
content?: unknown;
|
||
message?: unknown;
|
||
images?: unknown;
|
||
local_images?: unknown;
|
||
};
|
||
};
|
||
try {
|
||
entry = JSON.parse(line);
|
||
} catch {
|
||
continue;
|
||
}
|
||
if (full && entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
|
||
let text = typeof entry.payload.message === 'string' ? entry.payload.message.trim() : '';
|
||
if (text && isCodexInjectedContext(text)) continue;
|
||
if (text) eventUserTexts.set(text, (eventUserTexts.get(text) || 0) + 1);
|
||
// Image-only (or image+text) inputs: the text field alone would make
|
||
// the turn vanish, so surface a placeholder.
|
||
const imageCount =
|
||
(Array.isArray(entry.payload.images) ? entry.payload.images.length : 0) +
|
||
(Array.isArray(entry.payload.local_images) ? entry.payload.local_images.length : 0);
|
||
if (imageCount > 0) text = text ? `${text}\n\n*[image ×${imageCount}]*` : `*[image ×${imageCount}]*`;
|
||
if (text) messages.push({ role: 'user', text, timestamp: entry.timestamp });
|
||
continue;
|
||
}
|
||
if (entry.type !== 'response_item' || entry.payload?.type !== 'message') continue;
|
||
const role = entry.payload?.role;
|
||
if (role === 'assistant') {
|
||
const text = extractCodexBlockText(entry.payload?.content, ['output_text', 'text']);
|
||
if (text) {
|
||
lastText = text;
|
||
lastTimestamp = entry.timestamp || '';
|
||
if (full) messages.push({ role: 'assistant', text, timestamp: entry.timestamp });
|
||
}
|
||
} else if (role === 'user' && full) {
|
||
const text = extractCodexBlockText(entry.payload?.content, ['input_text', 'text']).trim();
|
||
// Drop Codex's injected context turns (AGENTS.md, environment_context, …)
|
||
// so the thread shows real user prompts only.
|
||
if (text && !isCodexInjectedContext(text)) {
|
||
messages.push({ role: 'user', text, timestamp: entry.timestamp, legacyUser: true });
|
||
}
|
||
}
|
||
}
|
||
|
||
const thread = messages
|
||
.filter((m) => {
|
||
if (!m.legacyUser) return true;
|
||
const n = eventUserTexts.get(m.text) || 0;
|
||
if (n > 0) {
|
||
eventUserTexts.set(m.text, n - 1);
|
||
return false; // duplicate of an event_msg row already in the thread
|
||
}
|
||
return true;
|
||
})
|
||
.map(({ role, text, timestamp }) => ({ role, text, timestamp }));
|
||
|
||
return full
|
||
? { text: lastText, timestamp: lastTimestamp, messages: thread }
|
||
: { text: lastText, timestamp: lastTimestamp };
|
||
}
|
||
|
||
// ========== Get Terminal Buffer ==========
|
||
|
||
// Query params:
|
||
// tail=<bytes> - Only return last N bytes (faster initial load)
|
||
// full=1 - Full page reload: replay the entire tmux scrollback (COD-47)
|
||
app.get('/api/sessions/:id/terminal', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const query = req.query as { tail?: string; full?: string };
|
||
const session = findSessionOrFail(ctx, id, req);
|
||
|
||
// `full=1` is the EXPLICIT full-reload signal (COD-47): the browser reloaded
|
||
// the page and wants the whole scroll history back, so we capture the ENTIRE
|
||
// tmux scrollback and the user gets back history that scrolled off Codeman's
|
||
// byte buffer. Requests WITHOUT it — tab switches (`tail=`) and the legacy
|
||
// no-param callers (response-viewer fallback, clearTerminal refresh) — keep
|
||
// the fast visible-frame capture.
|
||
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
|
||
const isFullReload = query.full === '1' || query.full === 'true';
|
||
const { tmuxHistoryLimit, terminalBufferMaxBytes } = await ctx.getTerminalHistoryConfig();
|
||
|
||
// Prepend the live tmux pane buffer so tab-switch replay shows the current
|
||
// on-screen frame, not just the accumulated byte history. This matters for
|
||
// TUI modes (codex/opencode) that repaint only their latest frame: the
|
||
// accumulated buffer alone replays as the idle banner. We clear the viewport
|
||
// (`\x1b[H\x1b[2J`) between the history and the live pane so they don't
|
||
// overlap. `captureActivePaneBuffer` is a no-op ('') under test mode and
|
||
// returns null when unavailable, in which case we fall back to history.
|
||
const muxName = session.muxName;
|
||
const liveMuxBuffer =
|
||
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
|
||
? ctx.mux.captureActivePaneBuffer(
|
||
muxName,
|
||
isFullReload
|
||
? { fullHistory: true, historyLimitLines: tmuxHistoryLimit, maxCaptureBytes: terminalBufferMaxBytes }
|
||
: undefined
|
||
)
|
||
: null;
|
||
const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0;
|
||
const source: 'history' | 'mux-visible' | 'mux-full-history' = hasLiveMuxBuffer
|
||
? isFullReload
|
||
? 'mux-full-history'
|
||
: 'mux-visible'
|
||
: 'history';
|
||
let rawBuffer: string;
|
||
if (liveMuxBuffer !== null && liveMuxBuffer.length > 0) {
|
||
// Full-history capture is the RENDERED form of everything already in the
|
||
// byte buffer (up to tmux eviction) — return it alone. Prepending the byte
|
||
// history would replay the whole conversation twice: `\x1b[2J` clears only
|
||
// the viewport, not xterm scrollback. The history+clear+frame concat stays
|
||
// for the visible-frame path, where the single pane frame lacks history.
|
||
rawBuffer = isFullReload
|
||
? liveMuxBuffer
|
||
: session.terminalBufferLength > 0
|
||
? `${session.terminalBuffer}\x1b[H\x1b[2J${liveMuxBuffer}`
|
||
: liveMuxBuffer;
|
||
} else {
|
||
rawBuffer = session.terminalBuffer;
|
||
}
|
||
const fullSize = rawBuffer.length;
|
||
let truncated = false;
|
||
let cleanBuffer: string;
|
||
|
||
// Cap the payload EARLY — before the regex normalization passes below run
|
||
// over it. A full-history tmux capture can be tens of MB of scrollback;
|
||
// normalizing all of it would stall the event loop only to discard most
|
||
// bytes anyway. Keep the most RECENT bytes (slice from the end) and align
|
||
// to a line boundary so we never start mid-ANSI-escape.
|
||
if (terminalBufferMaxBytes > 0 && rawBuffer.length > terminalBufferMaxBytes) {
|
||
rawBuffer = rawBuffer.slice(-terminalBufferMaxBytes);
|
||
truncated = true;
|
||
const capNewline = rawBuffer.indexOf('\n');
|
||
if (capNewline > 0 && capNewline < 4096) {
|
||
rawBuffer = rawBuffer.slice(capNewline + 1);
|
||
}
|
||
}
|
||
|
||
// Strip redundant Ink spinner/status redraws BEFORE tailing.
|
||
// During long thinking phases, Ink rewrites the same rows thousands of times
|
||
// (500KB+). Without stripping, tail mode returns only spinner frames and
|
||
// the terminal appears empty when switching tabs.
|
||
let strippedBuffer = session.mode === 'shell' ? rawBuffer : stripInkRedrawBloat(rawBuffer);
|
||
|
||
// Strip alt-screen toggles and scrollback-erase from Codex/Claude byte
|
||
// streams. xterm.js obeys them by switching to its scrollback-less alt
|
||
// buffer and wiping saved lines, so conversation history disappears on tab
|
||
// switch. Same gate as the live-stream strip in session.ts.
|
||
if (isAltScreenStripMode(session.mode)) {
|
||
strippedBuffer = strippedBuffer
|
||
.replace(ALT_SCREEN_TOGGLE_PATTERN, '')
|
||
.replace(ERASE_SCROLLBACK_PATTERN, '')
|
||
.replace(MOUSE_TRACKING_PATTERN, '');
|
||
} else if (isMuxAltScreenOnlyStripMode(session.mode, session.usesMux)) {
|
||
// tmux-backed shell/opencode/antigravity: drop tmux's own client smcup only.
|
||
// A byte buffer recorded before the live-side strip existed can still carry
|
||
// it, and one replayed `\x1b[?1049h` re-parks xterm in the alt buffer (#205).
|
||
strippedBuffer = strippedBuffer.replace(ALT_SCREEN_TOGGLE_PATTERN, '');
|
||
}
|
||
|
||
if (tailBytes > 0 && strippedBuffer.length > tailBytes) {
|
||
// Fast path: tail from the end, skip expensive banner search on full 2MB buffer.
|
||
// Banner is near the top and gets discarded by tail anyway.
|
||
cleanBuffer = strippedBuffer.slice(-tailBytes);
|
||
truncated = true;
|
||
// Avoid starting mid-ANSI-escape: find first newline within the first 4KB
|
||
// and start from there. This prevents xterm.js from parsing a partial escape
|
||
// sequence which corrupts cursor position for all subsequent Ink redraws.
|
||
const firstNewline = cleanBuffer.indexOf('\n');
|
||
if (firstNewline > 0 && firstNewline < 4096) {
|
||
cleanBuffer = cleanBuffer.slice(firstNewline + 1);
|
||
}
|
||
} else {
|
||
// Full buffer: clean junk before actual Claude content
|
||
cleanBuffer = strippedBuffer;
|
||
|
||
// Find where Claude banner starts (has color codes before "Claude")
|
||
const claudeMatch = cleanBuffer.match(CLAUDE_BANNER_PATTERN);
|
||
if (claudeMatch && claudeMatch.index !== undefined && claudeMatch.index > 0) {
|
||
let lineStart = claudeMatch.index;
|
||
while (lineStart > 0 && cleanBuffer[lineStart - 1] !== '\n') {
|
||
lineStart--;
|
||
}
|
||
cleanBuffer = cleanBuffer.slice(lineStart);
|
||
}
|
||
}
|
||
|
||
// Remove Ctrl+L and leading whitespace (cheap on tailed subset)
|
||
cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '').replace(LEADING_WHITESPACE_PATTERN, '');
|
||
|
||
return {
|
||
terminalBuffer: cleanBuffer,
|
||
status: session.status,
|
||
fullSize,
|
||
truncated,
|
||
source,
|
||
};
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Session Settings (auto-clear, auto-compact, image watcher, flicker filter)
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
// ========== Auto-Clear ==========
|
||
|
||
app.post('/api/sessions/:id/auto-clear', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const body = parseBody(AutoClearSchema, req.body, 'Invalid request body');
|
||
const session = findSessionOrFail(ctx, id, req);
|
||
|
||
session.setAutoClear(body.enabled, body.threshold);
|
||
persistAndBroadcastSession(ctx, session);
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
autoClear: {
|
||
enabled: session.autoClearEnabled,
|
||
threshold: session.autoClearThreshold,
|
||
},
|
||
},
|
||
};
|
||
});
|
||
|
||
// ========== Auto-Compact ==========
|
||
|
||
app.post('/api/sessions/:id/auto-compact', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const body = parseBody(AutoCompactSchema, req.body, 'Invalid request body');
|
||
const session = findSessionOrFail(ctx, id, req);
|
||
|
||
session.setAutoCompact(body.enabled, body.threshold, body.prompt);
|
||
persistAndBroadcastSession(ctx, session);
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
autoCompact: {
|
||
enabled: session.autoCompactEnabled,
|
||
threshold: session.autoCompactThreshold,
|
||
prompt: session.autoCompactPrompt,
|
||
},
|
||
},
|
||
};
|
||
});
|
||
|
||
// ========== Auto-Resume (usage-limit pause) ==========
|
||
|
||
app.post('/api/sessions/:id/auto-resume', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const body = parseBody(AutoResumeSchema, req.body, 'Invalid request body');
|
||
const session = findSessionOrFail(ctx, id, req);
|
||
|
||
session.setAutoResume(body.enabled);
|
||
persistAndBroadcastSession(ctx, session);
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
autoResume: {
|
||
enabled: session.autoResumeEnabled,
|
||
resumeAt: session.autoResumeAt ?? undefined,
|
||
},
|
||
},
|
||
};
|
||
});
|
||
|
||
// ========== Pin (float to top of the session manager list, COD-139) ==========
|
||
|
||
app.post('/api/sessions/:id/pin', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const body = parseBody(PinSessionSchema, req.body, 'Invalid request body');
|
||
|
||
const session = ctx.sessions.get(id);
|
||
if (session) {
|
||
if (!canAccessOwned(getAuthUser(req), session.owner)) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`);
|
||
}
|
||
session.setPinned(body.pinned);
|
||
// Persist + broadcast session:updated (keeps tabs/state consistent), then a
|
||
// dedicated session:pinned event so the session manager list re-sorts live.
|
||
persistAndBroadcastSession(ctx, session);
|
||
ctx.broadcast(SseEvent.SessionPinned, {
|
||
id,
|
||
pinned: session.pinned,
|
||
pinnedAt: session.pinnedAt ?? undefined,
|
||
});
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
pinned: session.pinned,
|
||
pinnedAt: session.pinnedAt ?? undefined,
|
||
},
|
||
};
|
||
}
|
||
|
||
// COD-142 keeps a pinned session's record after kill (demoteOrRemoveSession),
|
||
// so pin toggles must also work WITHOUT a live Session — otherwise a
|
||
// pinned-then-killed record could never be unpinned (cleanup skips pinned
|
||
// records, and the record has no live session to route through).
|
||
const persisted = ctx.store.getSession(id);
|
||
if (!persisted || !canAccessOwned(getAuthUser(req), persisted.owner)) {
|
||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`);
|
||
}
|
||
const pinnedAt = body.pinned ? Date.now() : undefined;
|
||
ctx.store.setSession(id, { ...persisted, pinned: body.pinned || undefined, pinnedAt });
|
||
ctx.broadcast(SseEvent.SessionPinned, { id, pinned: body.pinned, pinnedAt });
|
||
return { success: true, data: { pinned: body.pinned, pinnedAt } };
|
||
});
|
||
|
||
// ========== Image Watcher ==========
|
||
|
||
app.post('/api/sessions/:id/image-watcher', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const body = parseBody(ImageWatcherSchema, req.body, 'Invalid request body');
|
||
const session = findSessionOrFail(ctx, id, req);
|
||
|
||
if (body.enabled) {
|
||
imageWatcher.watchSession(session.id, session.workingDir);
|
||
} else {
|
||
imageWatcher.unwatchSession(session.id);
|
||
}
|
||
|
||
// Store state on session for persistence
|
||
session.imageWatcherEnabled = body.enabled;
|
||
ctx.persistSessionState(session);
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
imageWatcherEnabled: body.enabled,
|
||
},
|
||
};
|
||
});
|
||
|
||
// ========== Flicker Filter ==========
|
||
|
||
app.post('/api/sessions/:id/flicker-filter', async (req) => {
|
||
const { id } = req.params as { id: string };
|
||
const body = parseBody(FlickerFilterSchema, req.body, 'Invalid request body');
|
||
const session = findSessionOrFail(ctx, id, req);
|
||
|
||
session.flickerFilterEnabled = body.enabled;
|
||
persistAndBroadcastSession(ctx, session);
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
flickerFilterEnabled: body.enabled,
|
||
},
|
||
};
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Quick Actions (quick-run, quick-start)
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
// ========== Quick Run ==========
|
||
|
||
app.post('/api/run', async (req) => {
|
||
const runOwner = ownerFor(req);
|
||
const capMsg = sessionCapacityMessage(ctx.sessions, runOwner);
|
||
if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
|
||
|
||
const {
|
||
prompt,
|
||
workingDir,
|
||
envOverrides: runEnvOverrides,
|
||
} = parseBody(QuickRunSchema, req.body, 'Invalid request body');
|
||
|
||
if (!prompt.trim()) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required');
|
||
}
|
||
const dir = workingDir || process.cwd();
|
||
|
||
// Multi-user: confine a non-admin's one-shot working dir to their space.
|
||
if (!isWorkingDirAllowed(getAuthUser(req), dir)) {
|
||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
|
||
}
|
||
|
||
// Validate workingDir exists and is a directory
|
||
if (workingDir) {
|
||
try {
|
||
const stat = statSync(dir);
|
||
if (!stat.isDirectory()) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
|
||
}
|
||
} catch {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
|
||
}
|
||
}
|
||
|
||
// Section 6.3: the one-shot spawn path (runPrompt/buildPromptArgs) respects the
|
||
// session's claudeMode, so resolve it for the owner (bypass -> auto for non-granted).
|
||
const runClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||
const runClaudeMode = await resolveClaudeModeForUsername(runClaudeModeConfig.claudeMode, runOwner);
|
||
const session = new Session({
|
||
workingDir: dir,
|
||
envOverrides: runEnvOverrides,
|
||
claudeMode: runClaudeMode,
|
||
allowedTools: runClaudeModeConfig.allowedTools,
|
||
owner: runOwner,
|
||
});
|
||
ctx.addSession(session);
|
||
ctx.store.incrementSessionsCreated();
|
||
ctx.persistSessionState(session);
|
||
await ctx.setupSessionListeners(session);
|
||
getLifecycleLog().log({
|
||
event: 'created',
|
||
sessionId: session.id,
|
||
name: session.name,
|
||
reason: 'run_prompt',
|
||
});
|
||
|
||
ctx.broadcast(SseEvent.SessionCreated, ctx.getSessionStateWithRespawn(session));
|
||
|
||
try {
|
||
const result = await session.runPrompt(prompt);
|
||
// Clean up session after completion to prevent memory leak
|
||
await ctx.cleanupSession(session.id, true, 'run_prompt_complete');
|
||
return { sessionId: session.id, ...result };
|
||
} catch (err) {
|
||
// Clean up session on error too. The session is destroyed here, so its id
|
||
// is only useful for log correlation — carry it in the error message.
|
||
await ctx.cleanupSession(session.id, true, 'run_prompt_error');
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${getErrorMessage(err)} (session ${session.id})`);
|
||
}
|
||
});
|
||
|
||
// ========== Quick Start ==========
|
||
|
||
app.post('/api/quick-start', async (req) => {
|
||
const owner = ownerFor(req);
|
||
const capMsg = sessionCapacityMessage(ctx.sessions, owner);
|
||
if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
|
||
|
||
const {
|
||
caseName = 'testcase',
|
||
sessionName,
|
||
mode = 'claude',
|
||
modelOverride,
|
||
openCodeConfig,
|
||
codexConfig,
|
||
geminiConfig,
|
||
antigravityConfig,
|
||
envOverrides,
|
||
effort,
|
||
parentSessionId,
|
||
} = parseBody(QuickStartSchema, req.body);
|
||
|
||
// Multi-user: shell mode is arbitrary host-account execution, gated by the grant.
|
||
// Resolve the owner's grant from the store so a GRANTED regular user is not wrongly denied.
|
||
if (mode === 'shell' && !(await canUsernameRunPrivilegedCommands(owner))) {
|
||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant');
|
||
}
|
||
|
||
// Resolve the remote case FIRST — the CLI executes on the REMOTE host over ssh,
|
||
// so the LOCAL availability gates below (isCodexAvailable() etc.) don't apply and
|
||
// would wrongly reject a machine that hasn't got the CLI installed locally.
|
||
let remote = undefined;
|
||
let docker = undefined;
|
||
let dockerResumeId: string | undefined;
|
||
let casePath: string | null = null;
|
||
// Security: fold ownership INTO the match (don't early-return) so a NON-OWNED
|
||
// same-named remote/docker case is skipped and control falls through to the caller's
|
||
// own LOCAL case — remote/docker names are globally unique but local names are
|
||
// per-user, so a name collision must not shadow the caller's own case. canAccessOwned
|
||
// is allow-all for admins/single-user, so flag-OFF stays byte-identical.
|
||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||
const remoteCase = remoteCases.find(
|
||
(item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner)
|
||
);
|
||
const dockerCase = remoteCase
|
||
? undefined
|
||
: (await readDockerCases(CODEMAN_CONFIG_DIR)).find(
|
||
(item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner)
|
||
);
|
||
if (remoteCase) {
|
||
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
|
||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||
|
||
// Per-session config that is applied to the LOCAL tmux/CLI wrapper (env vars via
|
||
// tmux setenv, effort/model CLI args, codex/gemini/antigravity/opencode config) does NOT
|
||
// cross ssh, so it would silently no-op. Reject rather than pretend it worked —
|
||
// remote command/env customization goes through the per-host command override.
|
||
if (
|
||
(envOverrides && Object.keys(envOverrides).length > 0) ||
|
||
effort ||
|
||
modelOverride !== undefined ||
|
||
codexConfig ||
|
||
geminiConfig ||
|
||
antigravityConfig ||
|
||
openCodeConfig
|
||
) {
|
||
return createErrorResponse(
|
||
ApiErrorCode.INVALID_INPUT,
|
||
'envOverrides, effort, modelOverride, 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.'
|
||
);
|
||
}
|
||
|
||
// tmux is a hard prerequisite on the remote host (the agent runs inside a remote
|
||
// tmux server so it survives ssh drops). Probe before spawning so a missing tmux
|
||
// surfaces a clear, structured error instead of a dead "tmux: command not found" pane.
|
||
const tmuxCheck = await checkRemoteTmuxAvailable(host);
|
||
if (!tmuxCheck.ok) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'remote host is missing tmux');
|
||
}
|
||
|
||
casePath = remoteCase.remotePath;
|
||
remote = toSessionRemote(host, remoteCase);
|
||
} else if (dockerCase) {
|
||
// Docker case: the CLI executes INSIDE a container via local tmux + `docker
|
||
// exec`, so the LOCAL availability gates below don't apply. Mirror the remote
|
||
// branch's rejection of per-session config that would not cross into the
|
||
// container (it would silently no-op). (Ownership is enforced in the .find above.)
|
||
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
|
||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
|
||
if (
|
||
(envOverrides && Object.keys(envOverrides).length > 0) ||
|
||
effort ||
|
||
codexConfig ||
|
||
geminiConfig ||
|
||
antigravityConfig ||
|
||
openCodeConfig
|
||
) {
|
||
return createErrorResponse(
|
||
ApiErrorCode.INVALID_INPUT,
|
||
'envOverrides, effort, and per-CLI config are not supported for docker cases (they do not cross into the container). Configure the container via the docker host command override instead.'
|
||
);
|
||
}
|
||
|
||
const availability = await checkDockerAvailable(host.engine);
|
||
if (!availability.ok) {
|
||
return createErrorResponse(
|
||
ApiErrorCode.OPERATION_FAILED,
|
||
availability.error || 'docker daemon is not available'
|
||
);
|
||
}
|
||
const sessionDocker = toSessionDocker(host, dockerCase);
|
||
// Ensure the base image exists, auto-building the default image on first use so
|
||
// it is never a blocker. Dedup'd with any build kicked off at case-create, so
|
||
// this awaits the SAME in-flight build rather than starting a second one.
|
||
const ensured = await ensureAgentBaseImage(sessionDocker, sessionDocker.image, {
|
||
onProgress: (line) => ctx.broadcast(SseEvent.DockerImageBuildProgress, { name: dockerCase.name, line }),
|
||
});
|
||
if (!ensured.ok) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, ensured.error || 'base image not available');
|
||
}
|
||
if (ensured.built) {
|
||
ctx.broadcast(SseEvent.DockerImageBuildComplete, { name: dockerCase.name, image: sessionDocker.image });
|
||
}
|
||
// tmux is a hard prerequisite (the in-container tmux makes reconnect durable).
|
||
// Skip the extra container-run probe for our OWN default image (the baked
|
||
// Dockerfile always contains tmux); still verify a custom image.
|
||
if (sessionDocker.image !== DEFAULT_AGENT_IMAGE) {
|
||
const tmuxCheck = await checkDockerTmuxAvailable(sessionDocker);
|
||
if (!tmuxCheck.ok) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'base image is missing tmux');
|
||
}
|
||
}
|
||
|
||
// Config drift (docs/docker-cases-plan.md §4): the desired create-config no
|
||
// longer matches the existing container's codeman.confighash label. Refuse to
|
||
// silently launch into the stale container — the frontend confirms a recreate
|
||
// (POST /api/docker-cases/:name/recreate; workspace + transcripts ride bind
|
||
// mounts and the conversation resumes), or the user reverts the host edit.
|
||
const drift = await checkDockerConfigDrift(sessionDocker);
|
||
if (drift.exists && drift.drifted) {
|
||
return createErrorResponse(
|
||
ApiErrorCode.CONFLICT,
|
||
`Container config for case "${dockerCase.name}" changed since the container was created. Recreate the container to apply it (workspace and conversation survive), or revert the docker host edit.`
|
||
);
|
||
}
|
||
|
||
casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container)
|
||
docker = sessionDocker;
|
||
// Seed resume so a relaunch resumes the case's last conversation from the
|
||
// bind-mounted transcript (decision: resume-on-start default ON).
|
||
if (sessionDocker.resumeOnStart && dockerCase.lastClaudeSessionId) {
|
||
dockerResumeId = dockerCase.lastClaudeSessionId;
|
||
}
|
||
} else {
|
||
// Check OpenCode availability if requested
|
||
if (mode === 'opencode') {
|
||
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
||
if (!isOpenCodeAvailable()) {
|
||
return createErrorResponse(
|
||
ApiErrorCode.OPERATION_FAILED,
|
||
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
||
);
|
||
}
|
||
}
|
||
|
||
// Check Codex availability if requested
|
||
if (mode === 'codex') {
|
||
const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
|
||
if (!isCodexAvailable()) {
|
||
return createErrorResponse(
|
||
ApiErrorCode.OPERATION_FAILED,
|
||
'Codex CLI not found. Install with: npm install -g @openai/codex'
|
||
);
|
||
}
|
||
}
|
||
|
||
// Check Gemini availability if requested
|
||
if (mode === 'gemini') {
|
||
const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
|
||
if (!isGeminiAvailable()) {
|
||
return createErrorResponse(
|
||
ApiErrorCode.OPERATION_FAILED,
|
||
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
||
);
|
||
}
|
||
}
|
||
|
||
// Check Antigravity availability if requested
|
||
if (mode === 'antigravity') {
|
||
const { isAntigravityAvailable } = await import('../../utils/antigravity-cli-resolver.js');
|
||
if (!isAntigravityAvailable()) {
|
||
return createErrorResponse(
|
||
ApiErrorCode.OPERATION_FAILED,
|
||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
||
);
|
||
}
|
||
}
|
||
|
||
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
|
||
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
|
||
// external project directories are honoured by quick-start just like regular case routes.
|
||
let linkedCases: Record<string, string> = {};
|
||
try {
|
||
const raw = await fs.readFile(LINKED_CASES_FILE, 'utf-8');
|
||
linkedCases = JSON.parse(raw);
|
||
} catch {
|
||
// File missing or unparseable — treat as empty registry
|
||
}
|
||
// Multi-user: the linked-cases registry is ownerless/global, so only admins may
|
||
// resolve a name to an arbitrary linked path. A non-admin resolves inside their
|
||
// OWN case space only (single-user: isAdmin true, so linked cases still honoured).
|
||
const linked = isAdmin(req) ? linkedCases[caseName] : undefined;
|
||
casePath = linked || validatePathWithinBase(caseName, resolveCasesDir(getAuthUser(req)));
|
||
if (!casePath) {
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||
}
|
||
}
|
||
|
||
// By this point casePath is guaranteed non-null: for remote cases it was set from remoteCase.remotePath,
|
||
// for local cases the !casePath guard above returned early. TypeScript can't narrow across the if/else.
|
||
const resolvedCasePath = casePath as string;
|
||
|
||
// Multi-user linchpin (section 6.2): confine the resolved workingDir to the caller's
|
||
// own case space BEFORE any mkdir/scaffold below creates or mutates it. Applies to
|
||
// LOCAL and DOCKER cases (docker.hostWorkspacePath is a real host dir the file routes
|
||
// trust); skipped for REMOTE, whose path is an ssh path that would spuriously fail
|
||
// realpath confinement. No-op for admins / single-user mode.
|
||
if (!remote && !isWorkingDirAllowed(getAuthUser(req), resolvedCasePath)) {
|
||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'case path is outside your workspace');
|
||
}
|
||
|
||
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote,
|
||
// non-docker cases — docker workspaces are scaffolded in their own block below)
|
||
if (!remote && !docker && !existsSync(resolvedCasePath)) {
|
||
try {
|
||
mkdirSync(resolvedCasePath, { recursive: true });
|
||
mkdirSync(join(resolvedCasePath, 'src'), { recursive: true });
|
||
|
||
// Read settings to get custom template path
|
||
const templatePath = await ctx.getDefaultClaudeMdPath();
|
||
const claudeMd = generateClaudeMd(caseName, '', templatePath);
|
||
writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd);
|
||
|
||
// Write .claude/settings.local.json with hooks for desktop notifications
|
||
// (Claude-specific — OpenCode, Codex, Gemini, and Antigravity use their own systems)
|
||
if (mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini' && mode !== 'antigravity') {
|
||
await writeHooksConfig(resolvedCasePath);
|
||
}
|
||
|
||
ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: resolvedCasePath });
|
||
} catch (err) {
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
||
}
|
||
} else if (!remote && !docker && mode !== 'opencode') {
|
||
// COD-91 self-heal for an EXISTING case: refresh a pre-secret hooks block so the
|
||
// now-unconditional hook-secret gate keeps accepting its hook events. No-op when
|
||
// the hooks aren't ours or already carry the secret. Skipped for remote cases —
|
||
// resolvedCasePath is a REMOTE path that doesn't exist on the local filesystem.
|
||
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
|
||
}
|
||
|
||
// Agent skill injection (docs/agent-control-plan.md §2): ADD-ONLY on create,
|
||
// marker-guarded (a user's own skills/codeman is never touched). Claude mode only
|
||
// (`.claude/skills/` is a Claude Code surface); skipped for remote cases, whose
|
||
// casePath lives on another host. Docker cases qualify: hostWorkspacePath is a
|
||
// real host dir and the skill crosses the bind mount like the rest of `.claude/`.
|
||
if (!remote && mode === 'claude' && (await ctx.getAgentSkillEnabled())) {
|
||
await injectAgentSkill(resolvedCasePath);
|
||
}
|
||
|
||
// Docker cases: the workspace is a REAL host dir bind-mounted into the container.
|
||
// Scaffold hooks (+ a CLAUDE.md) if MISSING so in-container permission prompts and
|
||
// hook-idle detection fire (decision: wire hooks now). Never clobbers an existing
|
||
// configured project. Skipped for external CLIs (they use their own systems).
|
||
if (
|
||
docker &&
|
||
docker.hooksEnabled &&
|
||
mode !== 'opencode' &&
|
||
mode !== 'codex' &&
|
||
mode !== 'gemini' &&
|
||
mode !== 'antigravity'
|
||
) {
|
||
try {
|
||
if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) {
|
||
const templatePath = await ctx.getDefaultClaudeMdPath();
|
||
writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), generateClaudeMd(caseName, '', templatePath));
|
||
}
|
||
if (!existsSync(join(resolvedCasePath, '.claude', 'settings.local.json'))) {
|
||
await writeHooksConfig(resolvedCasePath);
|
||
} else {
|
||
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
|
||
}
|
||
} catch {
|
||
/* non-fatal — the session still runs, hooks may be degraded */
|
||
}
|
||
}
|
||
|
||
// Model override → <case>/.claude/settings.local.json (claude-mode; local AND
|
||
// docker — the docker workspace is a real host dir, so the settings file crosses
|
||
// the bind mount and the in-container claude reads it). Remote was rejected above.
|
||
if (mode === 'claude' && modelOverride !== undefined) {
|
||
await updateCaseModel(resolvedCasePath, modelOverride || null);
|
||
}
|
||
|
||
// Strip stale disk entries for keys this request is actively setting (Claude only —
|
||
// see POST /api/sessions for full rationale).
|
||
if (
|
||
mode !== 'opencode' &&
|
||
mode !== 'codex' &&
|
||
mode !== 'gemini' &&
|
||
mode !== 'antigravity' &&
|
||
!remote &&
|
||
envOverrides &&
|
||
Object.keys(envOverrides).length > 0
|
||
) {
|
||
await stripCaseEnvKeys(resolvedCasePath, Object.keys(envOverrides));
|
||
}
|
||
|
||
// Create a new session with the case as working directory
|
||
// Apply global Nice priority config and model config from settings
|
||
const niceConfig = await ctx.getGlobalNiceConfig();
|
||
const qsModelConfig = await ctx.getModelConfig();
|
||
const qsModel =
|
||
mode === 'opencode'
|
||
? openCodeConfig?.model
|
||
: mode === 'codex'
|
||
? codexConfig?.model
|
||
: mode === 'gemini'
|
||
? geminiConfig?.model
|
||
: mode === 'antigravity'
|
||
? antigravityConfig?.model
|
||
: mode !== 'shell'
|
||
? qsModelConfig?.defaultModel || undefined
|
||
: undefined;
|
||
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||
const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner);
|
||
// Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted).
|
||
const {
|
||
codexConfig: qsGatedCodexConfig,
|
||
geminiConfig: qsGatedGeminiConfig,
|
||
antigravityConfig: qsGatedAntigravityConfig,
|
||
} = await clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig, antigravityConfig);
|
||
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||
const session = new Session({
|
||
workingDir: resolvedCasePath,
|
||
name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
|
||
mux: ctx.mux,
|
||
useMux: true,
|
||
mode: mode,
|
||
niceConfig: niceConfig,
|
||
model: qsModel,
|
||
claudeMode: qsEffectiveClaudeMode,
|
||
allowedTools: qsClaudeModeConfig.allowedTools,
|
||
owner,
|
||
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
|
||
codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined,
|
||
geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined,
|
||
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
|
||
envOverrides,
|
||
effort,
|
||
remote,
|
||
docker,
|
||
resumeSessionId: dockerResumeId,
|
||
tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
|
||
parentSessionId: resolveParentSessionId(ctx, req, parentSessionId, owner),
|
||
});
|
||
|
||
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
||
// so the initial state already has the phrase configured (only if globally enabled)
|
||
if (mode === 'claude' && !remote && !docker && ctx.store.getConfig().ralphEnabled) {
|
||
autoConfigureRalph(session, resolvedCasePath, ctx);
|
||
if (!session.ralphTracker.enabled) {
|
||
session.ralphTracker.enable();
|
||
session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart
|
||
}
|
||
}
|
||
|
||
ctx.addSession(session);
|
||
ctx.store.incrementSessionsCreated();
|
||
ctx.persistSessionState(session);
|
||
await ctx.setupSessionListeners(session);
|
||
getLifecycleLog().log({
|
||
event: 'created',
|
||
sessionId: session.id,
|
||
name: session.name,
|
||
reason: 'quick_start',
|
||
});
|
||
ctx.broadcast(SseEvent.SessionCreated, ctx.getSessionStateWithRespawn(session));
|
||
|
||
// Start in the appropriate mode
|
||
try {
|
||
if (mode === 'shell') {
|
||
await session.startShell();
|
||
getLifecycleLog().log({
|
||
event: 'started',
|
||
sessionId: session.id,
|
||
name: session.name,
|
||
mode: 'shell',
|
||
});
|
||
ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode: 'shell' });
|
||
} else {
|
||
// 'claude', 'opencode', 'codex', 'gemini', and 'antigravity' modes use startInteractive()
|
||
await session.startInteractive();
|
||
getLifecycleLog().log({
|
||
event: 'started',
|
||
sessionId: session.id,
|
||
name: session.name,
|
||
mode,
|
||
});
|
||
ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode });
|
||
}
|
||
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
|
||
|
||
// Docker + claude: the pane command pins the conversation id (--session-id /
|
||
// --resume, claudeDockerPaneCommand), so persist it as the case's resume seed
|
||
// NOW — a later container stop/reboot relaunch resumes this conversation even
|
||
// if no in-container hook ever reaches the host (loopback bind, no bridge
|
||
// listener). Hook/last-response adoption updates it again after /clear.
|
||
if (docker && mode === 'claude') {
|
||
void persistDockerCaseClaudeSessionId(
|
||
CODEMAN_CONFIG_DIR,
|
||
docker.containerName,
|
||
session.claudeSessionId || session.id
|
||
).catch(() => {});
|
||
}
|
||
|
||
// Save lastUsedCase to settings for TUI/web sync
|
||
try {
|
||
const settingsFilePath = SETTINGS_PATH;
|
||
let settings: Record<string, unknown> = {};
|
||
try {
|
||
settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
|
||
} catch (err) {
|
||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||
}
|
||
settings.lastUsedCase = caseName;
|
||
const dir = dirname(settingsFilePath);
|
||
if (!existsSync(dir)) {
|
||
mkdirSync(dir, { recursive: true });
|
||
}
|
||
// Use async write to avoid blocking event loop
|
||
fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
|
||
// Non-critical but log for debugging
|
||
console.warn('[Server] Failed to save settings (lastUsedCase):', err);
|
||
});
|
||
} catch (err) {
|
||
// Non-critical but log for debugging
|
||
console.warn('[Server] Failed to prepare settings update:', err);
|
||
}
|
||
|
||
return {
|
||
sessionId: session.id,
|
||
casePath: resolvedCasePath,
|
||
caseName,
|
||
};
|
||
} catch (err) {
|
||
// Clean up session on error to prevent orphaned resources
|
||
await ctx.cleanupSession(session.id, true, 'quick_start_error');
|
||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||
}
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// History — list past Claude conversations for resume
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
/** Extract the text of the first user message from a JSONL transcript head. */
|
||
function extractFirstUserPrompt(head: string): string | undefined {
|
||
const MAX_PROMPT_LEN = 120;
|
||
// Iterate lines without allocating a full split array
|
||
let start = 0;
|
||
while (start < head.length) {
|
||
const end = head.indexOf('\n', start);
|
||
const line = end === -1 ? head.slice(start) : head.slice(start, end);
|
||
start = end === -1 ? head.length : end + 1;
|
||
if (!line.includes('"type":"user"')) continue;
|
||
try {
|
||
const entry = JSON.parse(line);
|
||
if (entry.type !== 'user' || !entry.message) continue;
|
||
const content = entry.message.content;
|
||
let text: string | undefined;
|
||
if (typeof content === 'string') {
|
||
text = content;
|
||
} else if (Array.isArray(content)) {
|
||
const textBlock = content.find((b: { type: string }) => b.type === 'text');
|
||
if (textBlock) text = textBlock.text;
|
||
}
|
||
if (!text) continue;
|
||
// Strip XML-like system/command tags and ANSI escapes from transcripts
|
||
text = text
|
||
.replace(/<[^>]+>/g, '')
|
||
.replace(new RegExp(String.raw`\x1b\[[0-9;]*[a-zA-Z]`, 'g'), '')
|
||
.trim()
|
||
.replace(/\s+/g, ' ');
|
||
if (!text) continue;
|
||
// Skip system-injected messages, slash command artifacts, and expanded skill prompts
|
||
if (
|
||
/^(Caveat:|init\b|clear\b|resume\b|\/[a-z][\w-]*\b|You are a |\[Request |Set model to )/i.test(text) ||
|
||
/^(Please )?(analyze|review) this codebase/i.test(text) ||
|
||
/^(Read|Implement the following) .+, then (search|list|check) /i.test(text) ||
|
||
/^\d+ vulnerabilit/i.test(text) ||
|
||
/\btoolu_/.test(text) ||
|
||
/^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+/.test(text) ||
|
||
/\b(sk-ant-|ANTHROPIC_API_KEY|API_KEY=|SECRET|TOKEN=)/i.test(text) ||
|
||
text.length < 8
|
||
)
|
||
continue;
|
||
return text.length > MAX_PROMPT_LEN ? text.slice(0, MAX_PROMPT_LEN) + '\u2026' : text;
|
||
} catch {
|
||
// Malformed line — skip
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
/**
|
||
* Is this `entrypoint` value an automated/SDK-driven invocation?
|
||
*
|
||
* ⚠️ Deliberately a BLOCKLIST on the SDK shape, not an allowlist on `'cli'`.
|
||
* The exclusion below hides rows, so an allowlist fails CLOSED on any value
|
||
* Claude Code has not shipped yet: the day it stamps a new interactive
|
||
* entrypoint (a rename, or a second interactive host), every transcript stops
|
||
* matching `'cli'` and the whole Past Sessions list goes blank with nothing in
|
||
* the UI to explain it. A blocklist fails OPEN instead — an automated
|
||
* entrypoint we do not recognize yet costs a few noisy rows, which is the
|
||
* annoyance this filter set out to fix rather than a broken feature.
|
||
*
|
||
* Observed values: `cli` (interactive), `sdk-cli` / `sdk-py` (automated).
|
||
*/
|
||
function isAutomatedEntrypoint(entrypoint: string): boolean {
|
||
return /^sdk(-|$)/.test(entrypoint);
|
||
}
|
||
|
||
/**
|
||
* The `entrypoint` field Claude Code stamps on its own message records:
|
||
* 'cli' for a real interactive session, something else (e.g. 'sdk-py') for
|
||
* an SDK/automated invocation. Used to exclude non-interactive transcripts
|
||
* (CI review bots, etc.) from the resumable history list — they were never
|
||
* something a user can resume into.
|
||
*
|
||
* Scans every `"type":"user"`/`"type":"assistant"` line with an entrypoint
|
||
* field — not just the first one — and returns 'cli' the moment ANY of them
|
||
* carries it. A transcript is excluded only when every entrypoint-bearing
|
||
* message says something else; "first field wins" would misattribute a
|
||
* transcript that started under an older Claude Code version (no entrypoint
|
||
* on its true first message) and later picked up a non-'cli' entrypoint on
|
||
* some later message, wrongly hiding a genuinely interactive session. This
|
||
* deliberately errs toward keeping a session visible: one real interactive
|
||
* message anywhere is enough. Returns undefined ("unknown", fail-open) only
|
||
* when nothing scanned carries the field at all.
|
||
*/
|
||
function extractTranscriptEntrypoint(text: string): string | undefined {
|
||
let start = 0;
|
||
let sawNonCli: string | undefined;
|
||
while (start < text.length) {
|
||
const end = text.indexOf('\n', start);
|
||
const line = end === -1 ? text.slice(start) : text.slice(start, end);
|
||
start = end === -1 ? text.length : end + 1;
|
||
if (!line.includes('"type":"user"') && !line.includes('"type":"assistant"')) continue;
|
||
if (!line.includes('"entrypoint"')) continue;
|
||
try {
|
||
const entry = JSON.parse(line);
|
||
if ((entry.type === 'user' || entry.type === 'assistant') && typeof entry.entrypoint === 'string') {
|
||
if (entry.entrypoint === 'cli') return 'cli';
|
||
sawNonCli ??= entry.entrypoint;
|
||
}
|
||
} catch {
|
||
// Malformed/truncated line — skip
|
||
}
|
||
}
|
||
return sawNonCli;
|
||
}
|
||
|
||
/** Git/worktree facts recovered from a transcript. Every field is optional —
|
||
* "unknown" must stay distinguishable from "not a worktree" (#265/#266). */
|
||
type TranscriptGitInfo = {
|
||
/** The literal `cwd` Claude Code stamped on its own records. */
|
||
cwd?: string;
|
||
gitBranch?: string;
|
||
worktreeName?: string;
|
||
/** Main repo root the worktree belongs to. */
|
||
worktreeRepo?: string;
|
||
};
|
||
|
||
/** `<repo>/.claude/worktrees/<name>` — the layout Claude Code's own worktree feature creates. */
|
||
const CLAUDE_WORKTREE_PATH = /^(.*)\/\.claude\/worktrees\/([^/]+)\/?$/;
|
||
|
||
/**
|
||
* Recover cwd / branch / worktree from a transcript chunk.
|
||
*
|
||
* Claude Code stamps `"cwd"` and `"gitBranch"` on every user/assistant record,
|
||
* and writes a dedicated `worktree-state` record when the session was started
|
||
* through its own worktree feature. This reads buffers `scanProjectDir` has
|
||
* ALREADY loaded, so it costs no extra file I/O.
|
||
*
|
||
* Why this matters beyond a label: `decodeProjectKey()` reconstructs a path by
|
||
* stat-walking the filesystem and falls back to `$HOME` when nothing resolves.
|
||
* A deleted worktree is the normal end of a worktree's life, so every past
|
||
* worktree session used to collapse onto `$HOME` (#265). The transcript value
|
||
* is the literal cwd — non-lossy, and it survives the directory being removed.
|
||
*
|
||
* cwd is taken from the FIRST record that carries it (a session's cwd does not
|
||
* move); gitBranch from the LAST (a branch genuinely changes mid-session, and
|
||
* the newest value in the scanned chunk is the closest to current).
|
||
*/
|
||
function extractTranscriptGitInfo(text: string): TranscriptGitInfo {
|
||
const info: TranscriptGitInfo = {};
|
||
let start = 0;
|
||
while (start < text.length) {
|
||
const end = text.indexOf('\n', start);
|
||
const line = end === -1 ? text.slice(start) : text.slice(start, end);
|
||
start = end === -1 ? text.length : end + 1;
|
||
|
||
// Highest-confidence source: Claude's own worktree record. Names the
|
||
// worktree explicitly, so it beats anything inferred from the path.
|
||
if (line.includes('"worktree-state"')) {
|
||
try {
|
||
const rec = JSON.parse(line) as {
|
||
worktreeSession?: { worktreeName?: unknown; worktreePath?: unknown; originalCwd?: unknown };
|
||
};
|
||
const ws = rec.worktreeSession;
|
||
if (ws) {
|
||
if (typeof ws.worktreeName === 'string') info.worktreeName ||= ws.worktreeName;
|
||
if (typeof ws.originalCwd === 'string') info.worktreeRepo ||= ws.originalCwd;
|
||
if (typeof ws.worktreePath === 'string') info.cwd ||= ws.worktreePath;
|
||
}
|
||
} catch {
|
||
// Malformed/truncated line — skip
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (!line.includes('"cwd"') && !line.includes('"gitBranch"')) continue;
|
||
if (!line.includes('"type":"user"') && !line.includes('"type":"assistant"')) continue;
|
||
try {
|
||
const rec = JSON.parse(line) as { cwd?: unknown; gitBranch?: unknown };
|
||
if (!info.cwd && typeof rec.cwd === 'string' && rec.cwd) info.cwd = rec.cwd;
|
||
// Last one wins — closest to the session's current branch.
|
||
if (typeof rec.gitBranch === 'string' && rec.gitBranch) info.gitBranch = rec.gitBranch;
|
||
} catch {
|
||
// Malformed/truncated line — skip
|
||
}
|
||
}
|
||
|
||
// No explicit worktree record: infer from Claude's own worktree path layout.
|
||
// A worktree created by hand (`git worktree add` anywhere) has no recoverable
|
||
// NAME here — it still gets a branch, and the badge degrades to branch-only
|
||
// rather than guessing.
|
||
if (!info.worktreeName && info.cwd) {
|
||
const m = CLAUDE_WORKTREE_PATH.exec(info.cwd);
|
||
if (m) {
|
||
info.worktreeName = m[2];
|
||
info.worktreeRepo ||= m[1];
|
||
}
|
||
}
|
||
return info;
|
||
}
|
||
|
||
/**
|
||
* Extract the text of the LAST user message from a JSONL transcript chunk
|
||
* (COD-145). Mirrors `extractFirstUserPrompt` exactly — same user-message
|
||
* detection, same noise/secret/slash-command filters, same 120-char cap — but
|
||
* keeps the last qualifying match instead of returning on the first. Scan the
|
||
* file tail for this (the most recent prompt lives near the end).
|
||
*/
|
||
function extractLastUserPrompt(text: string): string | undefined {
|
||
const MAX_PROMPT_LEN = 120;
|
||
let result: string | undefined;
|
||
let start = 0;
|
||
while (start < text.length) {
|
||
const end = text.indexOf('\n', start);
|
||
const line = end === -1 ? text.slice(start) : text.slice(start, end);
|
||
start = end === -1 ? text.length : end + 1;
|
||
if (!line.includes('"type":"user"')) continue;
|
||
try {
|
||
const entry = JSON.parse(line);
|
||
if (entry.type !== 'user' || !entry.message) continue;
|
||
const content = entry.message.content;
|
||
let msgText: string | undefined;
|
||
if (typeof content === 'string') {
|
||
msgText = content;
|
||
} else if (Array.isArray(content)) {
|
||
const textBlock = content.find((b: { type: string }) => b.type === 'text');
|
||
if (textBlock) msgText = textBlock.text;
|
||
}
|
||
if (!msgText) continue;
|
||
msgText = msgText
|
||
.replace(/<[^>]+>/g, '')
|
||
.replace(new RegExp(String.raw`\x1b\[[0-9;]*[a-zA-Z]`, 'g'), '')
|
||
.trim()
|
||
.replace(/\s+/g, ' ');
|
||
if (!msgText) continue;
|
||
if (
|
||
/^(Caveat:|init\b|clear\b|resume\b|\/[a-z][\w-]*\b|You are a |\[Request |Set model to )/i.test(msgText) ||
|
||
/^(Please )?(analyze|review) this codebase/i.test(msgText) ||
|
||
/^(Read|Implement the following) .+, then (search|list|check) /i.test(msgText) ||
|
||
/^\d+ vulnerabilit/i.test(msgText) ||
|
||
/\btoolu_/.test(msgText) ||
|
||
/^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+/.test(msgText) ||
|
||
/\b(sk-ant-|ANTHROPIC_API_KEY|API_KEY=|SECRET|TOKEN=)/i.test(msgText) ||
|
||
msgText.length < 8
|
||
)
|
||
continue;
|
||
result = msgText.length > MAX_PROMPT_LEN ? msgText.slice(0, MAX_PROMPT_LEN) + '…' : msgText;
|
||
} catch {
|
||
// Malformed line — skip
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Decode a Claude project key (e.g. "-Users-teigen-Documents-Workspace-AI-project-Mirror")
|
||
* back to a filesystem path ("/Users/teigen/Documents/Workspace/AI_project/Mirror").
|
||
*
|
||
* Claude CLI encodes both '/' and '_' as '-', so each '-' in the key could be
|
||
* any of: '/' (path separator), '_' (underscore), or '-' (literal dash).
|
||
*
|
||
* Strategy: recursive backtracking with longest-match-first preference.
|
||
* At each segment boundary, try joining as many segments as possible (with '_'
|
||
* or '-') into a single existing directory name. If a shorter match leads to a
|
||
* dead end, backtrack and try the next-shorter candidate.
|
||
*
|
||
* Why backtracking: when both `diary/` and `diary-app/` exist as siblings, the
|
||
* naive shortest-match would pick `diary` and then fail to find `app` inside,
|
||
* leaving the rest of the key unresolved. Longest-first picks `diary-app`.
|
||
*/
|
||
async function decodeProjectKey(projKey: string): Promise<string> {
|
||
const encoded = projKey.startsWith('-') ? projKey.slice(1) : projKey;
|
||
const segments = encoded.split('-');
|
||
|
||
const isDirCache = new Map<string, boolean>();
|
||
const isDir = async (p: string): Promise<boolean> => {
|
||
const cached = isDirCache.get(p);
|
||
if (cached !== undefined) return cached;
|
||
const result = await fs
|
||
.stat(p)
|
||
.then((s) => s.isDirectory())
|
||
.catch(() => false);
|
||
isDirCache.set(p, result);
|
||
return result;
|
||
};
|
||
|
||
// Recursive backtracking: returns the deepest valid path that consumes all
|
||
// segments. Tries the longest segment-join first at each step so that
|
||
// dash-containing directory names win over shorter same-prefix siblings.
|
||
async function tryDecode(idx: number, current: string): Promise<string | null> {
|
||
if (idx >= segments.length) return current;
|
||
const maxLook = Math.min(idx + 4, segments.length);
|
||
// Longest first: end = maxLook-1 down to idx
|
||
for (let end = maxLook - 1; end >= idx; end--) {
|
||
const candidates: string[] = [];
|
||
if (end === idx) {
|
||
// Skip an EMPTY segment: `isDir(current + '/' + '')` stats `current + '/'`,
|
||
// which always succeeds, so the empty candidate would match unconditionally
|
||
// and swallow the doubled dash that is the whole signature of a dotdir. It
|
||
// then resolves "/home/x/.sib" to "/home/x//sib" whenever a non-dot sibling
|
||
// exists, and shadows the dotdir branch below in every other case.
|
||
if (segments[idx] !== '') candidates.push(segments[idx]);
|
||
} else {
|
||
candidates.push(segments.slice(idx, end + 1).join('-'));
|
||
candidates.push(segments.slice(idx, end + 1).join('_'));
|
||
}
|
||
for (const child of candidates) {
|
||
const candidate = current + '/' + child;
|
||
if (await isDir(candidate)) {
|
||
const result = await tryDecode(end + 1, candidate);
|
||
if (result) return result;
|
||
}
|
||
}
|
||
}
|
||
// The encoder maps both '/' and '.' to '-', so a literal '.' in the
|
||
// original path (e.g. "/home/timkjr/.codeman") collapses into an empty
|
||
// split segment here. Retry this window as a dotdir/dotfile: ".<join>".
|
||
if (segments[idx] === '' && idx + 1 < segments.length) {
|
||
const dotMaxLook = Math.min(idx + 1 + 4, segments.length);
|
||
for (let end = dotMaxLook - 1; end >= idx + 1; end--) {
|
||
const dotCandidates =
|
||
end === idx + 1
|
||
? [segments[idx + 1]]
|
||
: [segments.slice(idx + 1, end + 1).join('-'), segments.slice(idx + 1, end + 1).join('_')];
|
||
for (const child of dotCandidates) {
|
||
const candidate = current + '/.' + child;
|
||
if (await isDir(candidate)) {
|
||
const result = await tryDecode(end + 1, candidate);
|
||
if (result) return result;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const decoded = await tryDecode(0, '');
|
||
if (decoded) return decoded;
|
||
|
||
// Fallback: greedy shortest-match (original behavior) — best effort when
|
||
// no fully-valid path exists (e.g. directory was deleted after the
|
||
// conversation was recorded).
|
||
let current = '';
|
||
let i = 0;
|
||
while (i < segments.length) {
|
||
let matched = false;
|
||
const maxLook = Math.min(i + 4, segments.length);
|
||
for (let end = i; end < maxLook; end++) {
|
||
const candidates: string[] = [];
|
||
if (end === i) {
|
||
// Same empty-segment skip as tryDecode above. This loop is shortest-match
|
||
// first, so without it the empty candidate matches on the very first try
|
||
// and sets `matched`, leaving the dotdir branch below permanently dead.
|
||
if (segments[i] !== '') candidates.push(segments[i]);
|
||
} else {
|
||
candidates.push(segments.slice(i, end + 1).join('_'));
|
||
candidates.push(segments.slice(i, end + 1).join('-'));
|
||
}
|
||
for (const child of candidates) {
|
||
const candidate = current + '/' + child;
|
||
if (await isDir(candidate)) {
|
||
current = candidate;
|
||
i = end + 1;
|
||
matched = true;
|
||
break;
|
||
}
|
||
}
|
||
if (matched) break;
|
||
}
|
||
if (!matched && segments[i] === '' && i + 1 < segments.length) {
|
||
const dotMaxLook = Math.min(i + 1 + 4, segments.length);
|
||
for (let end = i + 1; end < dotMaxLook; end++) {
|
||
const dotCandidates =
|
||
end === i + 1
|
||
? [segments[i + 1]]
|
||
: [segments.slice(i + 1, end + 1).join('_'), segments.slice(i + 1, end + 1).join('-')];
|
||
for (const child of dotCandidates) {
|
||
const candidate = current + '/.' + child;
|
||
if (await isDir(candidate)) {
|
||
current = candidate;
|
||
i = end + 1;
|
||
matched = true;
|
||
break;
|
||
}
|
||
}
|
||
if (matched) break;
|
||
}
|
||
}
|
||
if (!matched) {
|
||
if (segments[i] === '') {
|
||
// Nothing on disk matched (the usual reason this fallback runs at all is
|
||
// that the directory was deleted). An empty segment still means the
|
||
// encoder ate a literal '.', so guess the dotdir form rather than
|
||
// appending a bare '/' and emitting a "//" path.
|
||
if (i + 1 < segments.length) {
|
||
current = current + '/.' + segments[i + 1];
|
||
i += 2;
|
||
} else {
|
||
i++;
|
||
}
|
||
} else {
|
||
current = current + '/' + segments[i];
|
||
i++;
|
||
}
|
||
}
|
||
}
|
||
const finalExists = await fs
|
||
.access(current)
|
||
.then(() => true)
|
||
.catch(() => false);
|
||
return finalExists ? current : process.env.HOME || '/tmp';
|
||
}
|
||
|
||
/** Read the first `buf.length` bytes of a file for content sniffing. */
|
||
async function readFileHead(path: string, buf: Buffer): Promise<string | null> {
|
||
try {
|
||
const fd = await fs.open(path, 'r');
|
||
const { bytesRead } = await fd.read(buf, 0, buf.length, 0);
|
||
await fd.close();
|
||
return buf.toString('utf8', 0, bytesRead);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Read the last `buf.length` bytes of a file (for tail-scanning user prompts). */
|
||
async function readFileTail(path: string, buf: Buffer, fileSize: number): Promise<string | null> {
|
||
try {
|
||
const fd = await fs.open(path, 'r');
|
||
const offset = Math.max(0, fileSize - buf.length);
|
||
const { bytesRead } = await fd.read(buf, 0, buf.length, offset);
|
||
await fd.close();
|
||
const text = buf.toString('utf8', 0, bytesRead);
|
||
// Skip first partial line when we didn't read from the start
|
||
if (offset > 0) {
|
||
const nl = text.indexOf('\n');
|
||
return nl >= 0 ? text.slice(nl + 1) : null;
|
||
}
|
||
return text;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
type HistorySession = {
|
||
sessionId: string;
|
||
workingDir: string;
|
||
projectKey: string;
|
||
sizeBytes: number;
|
||
lastModified: string;
|
||
firstPrompt?: string;
|
||
lastPrompt?: string;
|
||
/** True when workingDir came from the transcript rather than decodeProjectKey's guess. */
|
||
workingDirExact?: boolean;
|
||
gitBranch?: string;
|
||
worktreeName?: string;
|
||
worktreeRepo?: string;
|
||
};
|
||
|
||
// Scan a single project directory and return all valid history sessions in it.
|
||
// Reused by both the global overview and the single-folder drill-down.
|
||
async function scanProjectDir(
|
||
projPath: string,
|
||
projDir: string,
|
||
smallHeadBuf: Buffer,
|
||
headBuf: Buffer
|
||
): Promise<HistorySession[]> {
|
||
const out: HistorySession[] = [];
|
||
const stat = await fs.stat(projPath).catch(() => null);
|
||
if (!stat?.isDirectory()) return out;
|
||
|
||
const workingDir = await decodeProjectKey(projDir);
|
||
const entries = await fs.readdir(projPath).catch(() => [] as string[]);
|
||
|
||
for (const entry of entries) {
|
||
if (!entry.endsWith('.jsonl')) continue;
|
||
const sessionId = entry.replace('.jsonl', '');
|
||
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(sessionId)) continue;
|
||
|
||
const filePath = join(projPath, entry);
|
||
const fileStat = await fs.stat(filePath).catch(() => null);
|
||
if (!fileStat) continue;
|
||
if (fileStat.size < 4000) continue;
|
||
|
||
const hasConversation = (text: string) =>
|
||
text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"');
|
||
|
||
// Two-tier head read: try the cheap smallHeadBuf (16KB) size first -- enough
|
||
// for the vast majority of transcripts -- and only escalate to the full
|
||
// headBuf (128KB) when that wasn't enough. Reading 128KB unconditionally for
|
||
// EVERY file in the directory roughly quadrupled the cost of a full scan
|
||
// (measured against a real ~/.claude/projects tree: ~4x both bytes read and
|
||
// wall time) to fix a problem only ~28% of files actually have. Escalating
|
||
// resolves the restart-bookkeeping case (the reason 128KB exists at all)
|
||
// without ever touching the tail-read fallback below for most of that 28%.
|
||
let head = await readFileHead(filePath, smallHeadBuf);
|
||
let foundContent = head ? hasConversation(head) : false;
|
||
let firstPrompt = head ? extractFirstUserPrompt(head) : undefined;
|
||
if ((!foundContent || !firstPrompt) && head !== null && fileStat.size > smallHeadBuf.length) {
|
||
const biggerHead = await readFileHead(filePath, headBuf);
|
||
if (biggerHead) {
|
||
head = biggerHead;
|
||
if (!foundContent) foundContent = hasConversation(head);
|
||
if (!firstPrompt) firstPrompt = extractFirstUserPrompt(head);
|
||
}
|
||
}
|
||
|
||
let tail: string | null = null;
|
||
// `head === null` (a failed read -- e.g. EMFILE while scanning hundreds of
|
||
// files) must also get a shot at the tail, not just "file bigger than the
|
||
// head buffer". Losing this dropped the session from history entirely
|
||
// instead of giving it a second chance, for any file at or under the head
|
||
// buffer size whose head read happened to fail.
|
||
if (!foundContent && (head === null || fileStat.size > headBuf.length)) {
|
||
const tailBuf = Buffer.alloc(32768);
|
||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||
if (tail) foundContent = hasConversation(tail);
|
||
}
|
||
if (!foundContent) continue;
|
||
|
||
// firstPrompt was already attempted from head (both tiers) above; this is
|
||
// purely the tail fallback for whatever's left unresolved.
|
||
if (!firstPrompt && (head === null || fileStat.size > headBuf.length)) {
|
||
if (!tail) {
|
||
const tailBuf = Buffer.alloc(32768);
|
||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||
}
|
||
if (tail) firstPrompt = extractFirstUserPrompt(tail);
|
||
}
|
||
|
||
// COD-145: last (most recent) user prompt lives near the END of the file, so
|
||
// prefer the tail. For large files where no tail was read yet, read one
|
||
// (mirrors the firstPrompt > headBuf.length block). Small files fit in `head`,
|
||
// which then contains the whole transcript — scan it for the last match instead.
|
||
if (!tail && fileStat.size > headBuf.length) {
|
||
const tailBuf = Buffer.alloc(32768);
|
||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||
}
|
||
const lastPrompt =
|
||
(tail ? extractLastUserPrompt(tail) : undefined) ?? (head ? extractLastUserPrompt(head) : undefined);
|
||
|
||
// Automated/SDK-driven invocations (CI review bots, etc.) write transcripts
|
||
// into the same ~/.claude/projects tree as interactive sessions but were
|
||
// never something a user can resume into — no PTY, no running process, and
|
||
// their "conversation" is typically a single one-shot prompt (often with a
|
||
// full diff embedded, which is exactly why it dwarfs this scanner's read
|
||
// windows and shows up above as blank or as an identical boilerplate
|
||
// sentence across many rows). Checked last, so it reuses whatever `head`/
|
||
// `tail` the prompt extraction above already read rather than triggering
|
||
// an extra file read. Missing entrypoint (older transcripts) reads as
|
||
// interactive — fail open, matching every other gating check in this
|
||
// codebase.
|
||
//
|
||
// head and tail are checked independently and merged with "cli wins" (not
|
||
// a first-truthy-value `??` chain): a large file's head might land on a
|
||
// non-'cli' message while a real interactive message sits in the tail (or
|
||
// vice versa), and either one being 'cli' is enough to keep the session.
|
||
const headEntrypoint = head ? extractTranscriptEntrypoint(head) : undefined;
|
||
const tailEntrypoint = tail ? extractTranscriptEntrypoint(tail) : undefined;
|
||
const entrypoint =
|
||
headEntrypoint === 'cli' || tailEntrypoint === 'cli' ? 'cli' : (headEntrypoint ?? tailEntrypoint);
|
||
if (entrypoint && isAutomatedEntrypoint(entrypoint)) continue;
|
||
|
||
// Git/worktree facts from the buffers already read above — no extra I/O.
|
||
// head first (cwd is stamped near the top; median offset ~1KB), tail as the
|
||
// fallback for transcripts whose head read failed or came up empty.
|
||
const headGit = head ? extractTranscriptGitInfo(head) : {};
|
||
const tailGit = tail ? extractTranscriptGitInfo(tail) : {};
|
||
const git: TranscriptGitInfo = {
|
||
cwd: headGit.cwd ?? tailGit.cwd,
|
||
// Last-wins within a chunk; across chunks the tail is the newer one.
|
||
gitBranch: tailGit.gitBranch ?? headGit.gitBranch,
|
||
worktreeName: headGit.worktreeName ?? tailGit.worktreeName,
|
||
worktreeRepo: headGit.worktreeRepo ?? tailGit.worktreeRepo,
|
||
};
|
||
|
||
out.push({
|
||
sessionId,
|
||
// The transcript's literal cwd beats decodeProjectKey's stat-walked guess,
|
||
// which silently collapses to $HOME once the directory is gone (#265).
|
||
// Absent cwd falls back to the old behaviour rather than inventing a path.
|
||
workingDir: git.cwd ?? workingDir,
|
||
workingDirExact: git.cwd !== undefined,
|
||
projectKey: projDir,
|
||
sizeBytes: fileStat.size,
|
||
lastModified: fileStat.mtime.toISOString(),
|
||
firstPrompt,
|
||
lastPrompt,
|
||
gitBranch: git.gitBranch,
|
||
worktreeName: git.worktreeName,
|
||
worktreeRepo: git.worktreeRepo,
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
app.get('/api/history/sessions', async (req) => {
|
||
const query = req.query as { projectKey?: string; offset?: string; limit?: string };
|
||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||
// scanProjectDir tries smallHeadBuf (16KB, the original size) first for every
|
||
// file and only escalates to headBuf (128KB) when that wasn't enough — see the
|
||
// comment at the escalation site in scanProjectDir for why unconditional 128KB
|
||
// reads were too expensive to keep. 128KB matches the existing precedent
|
||
// elsewhere in this file (line ~1431).
|
||
const smallHeadBuf = Buffer.alloc(16384);
|
||
const headBuf = Buffer.alloc(131072);
|
||
// Multi-user: this scans the host-wide ~/.claude/projects tree, so a non-admin
|
||
// must only see history whose decoded workingDir is inside their own case space.
|
||
// Do NOT trust the caller-supplied projectKey — confine on the decoded path.
|
||
// No-op for admins / single-user mode.
|
||
const user = getAuthUser(req);
|
||
const scopeHistory = isMultiUserMode() && user.role !== 'admin';
|
||
|
||
// Single-folder drill-down: when projectKey is provided, scan only that
|
||
// directory, bypass the 50-cap, and honor offset/limit pagination.
|
||
if (query.projectKey) {
|
||
// Validate projectKey format to prevent path traversal
|
||
if (!/^[A-Za-z0-9_-]+$/.test(query.projectKey)) {
|
||
return { sessions: [], total: 0 };
|
||
}
|
||
const offset = Math.max(0, parseInt(query.offset || '0', 10) || 0);
|
||
const limit = Math.min(100, Math.max(1, parseInt(query.limit || '20', 10) || 20));
|
||
const projPath = join(projectsDir, query.projectKey);
|
||
let all = await scanProjectDir(projPath, query.projectKey, smallHeadBuf, headBuf);
|
||
// Confine to the caller's workspace (a projectKey maps to a single foreign cwd).
|
||
if (scopeHistory) all = all.filter((r) => isWorkingDirAllowed(user, r.workingDir));
|
||
all.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
||
return { sessions: all.slice(offset, offset + limit), total: all.length };
|
||
}
|
||
|
||
// Global overview: scan all projects, return up to 50 most-recent sessions.
|
||
let results: HistorySession[] = [];
|
||
try {
|
||
const projectDirs = await fs.readdir(projectsDir);
|
||
for (const projDir of projectDirs) {
|
||
const projPath = join(projectsDir, projDir);
|
||
const list = await scanProjectDir(projPath, projDir, smallHeadBuf, headBuf);
|
||
results.push(...list);
|
||
}
|
||
} catch {
|
||
// Projects dir may not exist
|
||
}
|
||
|
||
// Multi-user: drop rows outside the non-admin caller's own case space.
|
||
if (scopeHistory) results = results.filter((r) => isWorkingDirAllowed(user, r.workingDir));
|
||
results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
||
return { sessions: results.slice(0, 50) };
|
||
});
|
||
|
||
/**
|
||
* Gather the four read-only views the unified list is merged from, plus mux
|
||
* stats. This is the expensive half (the lifecycle log and a scan of every
|
||
* Claude transcript), factored out of the route handler because the
|
||
* past-session search index rebuilds itself from the very same inputs, off
|
||
* the request path, see session-history-index.ts.
|
||
*/
|
||
async function gatherUnifiedInputs(): Promise<{
|
||
live: LiveSessionInput[];
|
||
persisted: PersistedSessionInput[];
|
||
lifecycle: LifecycleInput[];
|
||
history: HistoryInput[];
|
||
mux: MuxStatInput[];
|
||
}> {
|
||
// Live (in-memory) sessions.
|
||
const live: LiveSessionInput[] = [...ctx.sessions.values()].map((s) => {
|
||
const st = s.toState();
|
||
return {
|
||
id: st.id,
|
||
name: st.name,
|
||
mode: st.mode,
|
||
status: st.status,
|
||
isWorking: s.isWorking,
|
||
workingDir: st.workingDir,
|
||
createdAt: st.createdAt,
|
||
lastActivityAt: st.lastActivityAt,
|
||
claudeSessionId: s.claudeSessionId ?? undefined,
|
||
pinned: st.pinned,
|
||
pinnedAt: st.pinnedAt,
|
||
};
|
||
});
|
||
|
||
// Persisted sessions (state.json). resumeSessionId is the Claude
|
||
// conversation UUID a resumed session continues — feed it to the merge's
|
||
// alias map so its transcript row folds into this session.
|
||
const persisted: PersistedSessionInput[] = Object.values(ctx.store.getState().sessions).map((p) => ({
|
||
id: p.id,
|
||
name: p.name,
|
||
mode: p.mode,
|
||
status: p.status,
|
||
workingDir: p.workingDir,
|
||
createdAt: p.createdAt,
|
||
lastActivityAt: p.lastActivityAt,
|
||
claudeSessionId: p.resumeSessionId,
|
||
pinned: p.pinned,
|
||
pinnedAt: p.pinnedAt,
|
||
}));
|
||
|
||
// Lifecycle audit log (newest-first, capped).
|
||
let lifecycle: LifecycleInput[] = [];
|
||
try {
|
||
const entries = await getLifecycleLog().query({ limit: 2000 });
|
||
lifecycle = entries.map((e) => ({
|
||
sessionId: e.sessionId,
|
||
name: e.name,
|
||
mode: e.mode,
|
||
ts: e.ts,
|
||
event: e.event,
|
||
}));
|
||
} catch {
|
||
// Lifecycle log may be unavailable; treat as empty.
|
||
}
|
||
|
||
// Transcript history (~/.claude/projects) — reuse the same scanner as the overview.
|
||
const history: HistoryInput[] = [];
|
||
try {
|
||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||
// See the sibling allocation above for why there are two sizes.
|
||
const smallHeadBuf = Buffer.alloc(16384);
|
||
const headBuf = Buffer.alloc(131072);
|
||
const projectDirs = await fs.readdir(projectsDir);
|
||
for (const projDir of projectDirs) {
|
||
const projPath = join(projectsDir, projDir);
|
||
const list = await scanProjectDir(projPath, projDir, smallHeadBuf, headBuf);
|
||
for (const h of list) {
|
||
history.push({
|
||
sessionId: h.sessionId,
|
||
workingDir: h.workingDir,
|
||
sizeBytes: h.sizeBytes,
|
||
lastModified: h.lastModified,
|
||
firstPrompt: h.firstPrompt,
|
||
lastPrompt: h.lastPrompt,
|
||
projectKey: h.projectKey,
|
||
gitBranch: h.gitBranch,
|
||
worktreeName: h.worktreeName,
|
||
worktreeRepo: h.worktreeRepo,
|
||
});
|
||
}
|
||
}
|
||
} catch {
|
||
// Projects dir may not exist.
|
||
}
|
||
|
||
// Mux process stats (best-effort; guard against mocks lacking the method).
|
||
let mux: MuxStatInput[] = [];
|
||
try {
|
||
const getStats = (ctx.mux as { getSessionsWithStats?: () => Promise<unknown[]> }).getSessionsWithStats;
|
||
if (typeof getStats === 'function') {
|
||
const muxSessions = (await getStats.call(ctx.mux)) as Array<{
|
||
sessionId: string;
|
||
muxName?: string;
|
||
mode?: string;
|
||
remote?: unknown;
|
||
stats?: { memoryMB: number; cpuPercent: number };
|
||
}>;
|
||
mux = muxSessions.map((m) => ({
|
||
sessionId: m.sessionId,
|
||
muxName: m.muxName,
|
||
mode: m.mode,
|
||
remote: m.remote !== undefined ? true : undefined,
|
||
stats: m.stats ? { memoryMB: m.stats.memoryMB, cpuPercent: m.stats.cpuPercent } : undefined,
|
||
}));
|
||
}
|
||
} catch {
|
||
// Mux stats are optional.
|
||
}
|
||
|
||
return { live, persisted, lifecycle, history, mux };
|
||
}
|
||
|
||
/**
|
||
* Publish a merged unified list as the past-session search index (issue #261).
|
||
* The snapshot is stored UNSCOPED with a per-row owner, so it must only ever be
|
||
* built from an unscoped merge, `harvestSources()` in search-routes re-applies
|
||
* the ownership check on read.
|
||
*/
|
||
function publishHistorySessionIndex(merged: UnifiedSessionItem[]): void {
|
||
const ownerById = new Map<string, string | undefined>();
|
||
const stored = ctx.store.getState().sessions as Record<string, { id: string; owner?: string }>;
|
||
for (const p of Object.values(stored)) ownerById.set(p.id, p.owner);
|
||
// Live wins: a session's owner on disk can lag the running one.
|
||
for (const s of ctx.sessions.values()) ownerById.set(s.id, s.owner);
|
||
const liveIds = new Set(ctx.sessions.keys());
|
||
setHistorySessionIndex(buildHistorySessionIndexItems(merged, ownerById, liveIds));
|
||
}
|
||
|
||
// Rebuild hook for the search route: it kicks this (fire-and-forget) when the
|
||
// snapshot goes stale, so a search never pays for the scan itself.
|
||
setHistoryIndexRefresher(async () => {
|
||
if (ctx.testMode) return;
|
||
publishHistorySessionIndex(mergeUnifiedSessions(await gatherUnifiedInputs()));
|
||
});
|
||
|
||
// Unified, read-only session list: merges live + persisted + lifecycle +
|
||
// transcript history + mux stats into one de-duplicated, searchable list
|
||
// (COD-121). Pure merge/filter logic lives in unified-session-service.ts.
|
||
app.get('/api/sessions/unified', async (req) => {
|
||
const query = req.query as { q?: string; offset?: string; limit?: string };
|
||
|
||
if (ctx.testMode) {
|
||
return { sessions: [], total: 0 };
|
||
}
|
||
|
||
const { live, persisted, lifecycle, history, mux } = await gatherUnifiedInputs();
|
||
|
||
// Multi-user: a non-admin only sees their own sessions; host-wide transcript
|
||
// history (not tied to an owned session) is admin-only.
|
||
let sLive = live;
|
||
let sPersisted = persisted;
|
||
let sLifecycle = lifecycle;
|
||
let sHistory = history;
|
||
let scoped = false;
|
||
const uUser = getAuthUser(req);
|
||
if (isMultiUserMode() && uUser.role !== 'admin') {
|
||
scoped = true;
|
||
const ownedLive = new Set(
|
||
[...ctx.sessions.values()].filter((s) => canAccessOwned(uUser, s.owner)).map((s) => s.id)
|
||
);
|
||
const stored = ctx.store.getState().sessions as Record<string, { id: string; owner?: string }>;
|
||
const ownedPersisted = new Set(
|
||
Object.values(stored)
|
||
.filter((p) => canAccessOwned(uUser, p.owner))
|
||
.map((p) => p.id)
|
||
);
|
||
const isOwned = (id: string) => ownedLive.has(id) || ownedPersisted.has(id);
|
||
sLive = live.filter((l) => isOwned(l.id));
|
||
sPersisted = persisted.filter((p) => isOwned(p.id));
|
||
sLifecycle = lifecycle.filter((e) => isOwned(e.sessionId));
|
||
sHistory = [];
|
||
}
|
||
|
||
const merged = mergeUnifiedSessions({
|
||
live: sLive,
|
||
persisted: sPersisted,
|
||
lifecycle: sLifecycle,
|
||
history: sHistory,
|
||
mux,
|
||
});
|
||
|
||
// Refresh the search index off the back of this request, the home screen
|
||
// fetches this endpoint whenever it opens, which is the same screen the
|
||
// search box lives on, so the snapshot is warm before anyone types. A scoped
|
||
// merge is a per-user subset and would corrupt the shared snapshot, so that
|
||
// path re-merges unscoped instead (multi-user is opt-in and rarely hit).
|
||
publishHistorySessionIndex(scoped ? mergeUnifiedSessions({ live, persisted, lifecycle, history, mux }) : merged);
|
||
|
||
const offset = query.offset !== undefined ? parseInt(query.offset, 10) : undefined;
|
||
const limit = query.limit !== undefined ? parseInt(query.limit, 10) : undefined;
|
||
return filterAndPaginate(merged, {
|
||
q: query.q,
|
||
offset: Number.isNaN(offset as number) ? undefined : offset,
|
||
limit: Number.isNaN(limit as number) ? undefined : limit,
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Paste Image (clipboard / drag-drop upload)
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.heic', '.heif']);
|
||
// The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts).
|
||
|
||
app.post('/api/sessions/:id/paste-image', async (req, reply) => {
|
||
// CSRF defense: state-changing routes must come from same origin.
|
||
// Cookies are SameSite=lax, multipart/form-data is a "simple" CORS request
|
||
// (no preflight), so a cross-origin <form enctype="multipart/form-data">
|
||
// submit attaches the session cookie unimpeded. Reject unless Origin/Referer
|
||
// matches req.host. Non-browser clients (no Origin AND no Referer) must
|
||
// supply X-Codeman-CSRF — a header browsers cannot add cross-origin without
|
||
// a preflight, which our CORS config does not allow from other origins.
|
||
const reqHost = req.headers.host;
|
||
const origin = req.headers.origin;
|
||
const referer = req.headers.referer;
|
||
let csrfOk = false;
|
||
if (origin) {
|
||
try {
|
||
csrfOk = new URL(origin).host === reqHost;
|
||
} catch {
|
||
/* invalid Origin → not ok */
|
||
}
|
||
} else if (referer) {
|
||
try {
|
||
csrfOk = new URL(referer).host === reqHost;
|
||
} catch {
|
||
/* invalid Referer → not ok */
|
||
}
|
||
} else {
|
||
csrfOk = !!req.headers['x-codeman-csrf'];
|
||
}
|
||
if (!csrfOk) {
|
||
reply.code(403);
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'CSRF check failed');
|
||
}
|
||
|
||
const { id } = req.params as { id: string };
|
||
|
||
// Rate limit per (IP, sessionId): 30/min. Defends against disk-fill DoS
|
||
// — even an authenticated attacker can otherwise loop large image POSTs.
|
||
if (!consumePasteToken(`${req.ip}:${id}`)) {
|
||
reply.code(429);
|
||
reply.header('Retry-After', '60');
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Rate limit exceeded (30 uploads/min per session)');
|
||
}
|
||
|
||
const session = findSessionOrFail(ctx, id, req);
|
||
|
||
if (!req.isMultipart()) {
|
||
reply.code(400);
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
|
||
}
|
||
|
||
// Read the single file part. @fastify/multipart enforces the per-file size
|
||
// cap (MAX_PASTE_IMAGE_BYTES) and the 1-file/4-field count limits (server.ts),
|
||
// replacing a hand-rolled
|
||
// boundary scanner with several bugs: literal boundary matches anywhere in
|
||
// body, LF-only clients silently corrupted the last byte (hard-coded \r\n
|
||
// offsets), no part-count cap.
|
||
let part: import('@fastify/multipart').MultipartFile | undefined;
|
||
try {
|
||
part = await req.file();
|
||
} catch (err: unknown) {
|
||
reply.code(413);
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err) || 'Invalid multipart payload');
|
||
}
|
||
if (!part) {
|
||
reply.code(400);
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No image uploaded');
|
||
}
|
||
if (part.fieldname !== 'image') {
|
||
reply.code(400);
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unexpected field "${part.fieldname}", expected "image"`);
|
||
}
|
||
let imageBytes: Buffer;
|
||
try {
|
||
imageBytes = await part.toBuffer();
|
||
} catch (err: unknown) {
|
||
reply.code(413);
|
||
const maxMb = Math.round(MAX_PASTE_IMAGE_BYTES / (1024 * 1024));
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err) || `File too large (max ${maxMb}MB)`);
|
||
}
|
||
if (imageBytes.length === 0) {
|
||
reply.code(400);
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Empty file');
|
||
}
|
||
|
||
// Determine extension from filename or Content-Type.
|
||
let ext = '.png';
|
||
if (part.filename) {
|
||
const origExt = extname(part.filename).toLowerCase();
|
||
if (ALLOWED_IMAGE_EXTS.has(origExt)) ext = origExt;
|
||
}
|
||
const mimeMatch = (part.mimetype || '').toLowerCase().match(/^image\/(png|jpeg|jpg|webp|gif|bmp|heic|heif)$/);
|
||
if (mimeMatch) {
|
||
const map: Record<string, string> = {
|
||
png: '.png',
|
||
jpeg: '.jpg',
|
||
jpg: '.jpg',
|
||
webp: '.webp',
|
||
gif: '.gif',
|
||
bmp: '.bmp',
|
||
heic: '.heic',
|
||
heif: '.heif',
|
||
};
|
||
ext = map[mimeMatch[1]] ?? ext;
|
||
}
|
||
|
||
if (!ALLOWED_IMAGE_EXTS.has(ext)) {
|
||
reply.code(400);
|
||
return createErrorResponse(
|
||
ApiErrorCode.INVALID_INPUT,
|
||
`Unsupported image type: ${ext}. Allowed: ${[...ALLOWED_IMAGE_EXTS].join(', ')}`
|
||
);
|
||
}
|
||
|
||
// Route HEIC on the raw bytes, NOT the declared ext/mime: on some Android
|
||
// galleries (e.g. MIUI) a HEIF comes back mislabeled as image/jpeg, and
|
||
// browsers that cannot decode HEIF upload the original file as-is — so a
|
||
// HEIC payload can arrive under any declared type. Filename and
|
||
// Content-Type are attacker-supplied anyway; only the bytes are trusted.
|
||
if (imageMagicMatchesExt(imageBytes, '.heic')) {
|
||
try {
|
||
imageBytes = await convertHeicToJpeg(imageBytes);
|
||
ext = '.jpg';
|
||
} catch (err: unknown) {
|
||
console.warn(
|
||
`[paste-image] HEIC conversion failed: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} error=${getErrorMessage(err)}`
|
||
);
|
||
reply.code(415);
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Could not convert HEIC image to JPEG');
|
||
}
|
||
} else if (!imageMagicMatchesExt(imageBytes, ext)) {
|
||
// Sniff actual bytes — a polyglot HTML/PNG would otherwise pass and
|
||
// serve back with image/png MIME. Log the real header so format
|
||
// mismatches can be pinned down without a reproduce-and-guess loop. The
|
||
// client re-encodes images to JPEG/PNG before upload, so this is rare.
|
||
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')}`
|
||
);
|
||
reply.code(415);
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
|
||
}
|
||
|
||
// Save to {workingDir}/.claude-images/
|
||
// Refuse symlinks at imageDir — an agent or postinstall script could plant
|
||
// `.claude-images -> ~/.ssh/` and redirect future writes outside workingDir.
|
||
// We lstat (not stat) so we see the symlink itself. Use mkdir without
|
||
// `recursive` so the leaf creation does not follow a symlink either, and
|
||
// O_EXCL|O_NOFOLLOW on the file open so the write itself is symlink-safe.
|
||
const imageDir = join(session.workingDir, '.claude-images');
|
||
try {
|
||
const dirStat = await fs.lstat(imageDir);
|
||
if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
|
||
reply.code(403);
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '.claude-images is not a regular directory');
|
||
}
|
||
} catch (err: unknown) {
|
||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||
// Non-recursive mkdir: does not follow symlinks for the leaf.
|
||
// session.workingDir is guaranteed to exist (live session).
|
||
try {
|
||
await fs.mkdir(imageDir);
|
||
} catch (mkErr: unknown) {
|
||
// Concurrent uploads (a batch of photos) race to create .claude-images —
|
||
// the losers get EEXIST. Treat an already-present REAL directory as
|
||
// success, but re-verify it isn't a symlink a racing actor planted
|
||
// (preserve the symlink-safety guarantee above).
|
||
if ((mkErr as NodeJS.ErrnoException).code !== 'EEXIST') throw mkErr;
|
||
const raceStat = await fs.lstat(imageDir);
|
||
if (raceStat.isSymbolicLink() || !raceStat.isDirectory()) {
|
||
reply.code(403);
|
||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '.claude-images is not a regular directory');
|
||
}
|
||
}
|
||
}
|
||
// Date.now() collides on same-ms uploads from two tabs (last-write wins
|
||
// silently). Append 8 hex chars so concurrent pastes get distinct names.
|
||
const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
|
||
const filepath = join(imageDir, filename);
|
||
// O_EXCL: refuse to overwrite (collision is impossible with random suffix,
|
||
// but defends against TOCTOU). O_NOFOLLOW: refuse if filepath is a symlink.
|
||
const fh = await fs.open(
|
||
filepath,
|
||
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW
|
||
);
|
||
try {
|
||
await fh.writeFile(imageBytes);
|
||
} finally {
|
||
await fh.close();
|
||
}
|
||
|
||
return { path: filepath, filename };
|
||
});
|
||
}
|