Merge branch 'master' into feat/codex-resume

master and this branch both rewrote the two `_claudeSessionId` resets inside
`start()`, so `src/session.ts` conflicted at both of them.

master's commit ccfda623 puts `restoredConversation` at the head of each
fallback chain. A restored mux attach means the CLI never stopped, so a
`/clear` before the Codeman restart may already have moved it to a
conversation the launch id knows nothing about. The persisted chain's tail is
that conversation, and the CLI's own hook reported it first-hand.

This branch adds `this._codexConfig?.resumeSessionId` to the same two chains,
so a resumed codex session keeps its thread-id alias across every mux reattach
and boot recovery.

Both fixes belong. Each chain now reads restoredConversation, then
_resumeSessionId, then omp's alias, then codex's alias, then the launch id.
The comments from both sides are kept.

test/session-claude-conversation-chain.test.ts pins the shape of those two
assignments by matching the source text, and its pattern named omp's alias as
the last term before `this.id`. Codex's alias now sits between the two, so the
pattern widens to pin the ends of the chain and let the middle grow. A `[^;]`
run cannot cross a statement boundary, so each match is still one assignment.

Checked on the merged tree: typecheck, lint, prettier and the frontend syntax
check all pass, and the CI suite runs 6721 tests green across 349 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Grundberg
2026-09-07 08:48:31 +02:00
co-authored by Claude Opus 5
86 changed files with 7928 additions and 262 deletions
+229 -3
View File
@@ -13,7 +13,7 @@ import fs from 'node:fs/promises';
import { join, resolve, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import type { ApiResponse, CaseInfo, DockerHost, RemoteSessionInfo, SessionDocker } from '../../types.js';
import type { ApiResponse, CaseInfo, DockerHost, RemoteSessionInfo, SessionDocker, SessionMode } from '../../types.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import {
CreateCaseSchema,
@@ -24,6 +24,9 @@ import {
RemoteCaseLinkSchema,
RemoteHostSchema,
DockerCaseLinkSchema,
DockerCaseAdoptSchema,
DockerAdoptPreflightSchema,
DockerBrowseSchema,
DockerHostSchema,
DockerExportSchema,
DockerImportSchema,
@@ -66,6 +69,10 @@ import {
DEFAULT_AGENT_IMAGE,
dockerContainerName,
dockerDisplayPath,
probeAdoptableContainer,
listDockerContainers,
browseInContainer,
dockerAdoptProbeModes,
readDockerCases,
readDockerHosts,
removeDockerContainer,
@@ -73,6 +80,7 @@ import {
writeDockerCases,
writeDockerHosts,
} from '../../docker-hosts.js';
import type { AdoptedContainerProbe, DockerBrowseResult, DockerContainerInfo } from '../../docker-hosts.js';
import { buildDockerRemoveCommand } from '../../tmux-manager.js';
import {
checkRemoteTmuxAvailable,
@@ -291,6 +299,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
image: host.image,
path: dockerCase.hostWorkspacePath,
network: host.network ?? 'bridge',
...(dockerCase.availableModes ? { availableModes: dockerCase.availableModes } : {}),
...(dockerCase.owned === false ? { owned: false } : {}),
},
};
const existingIndex = cases.findIndex((item) => item.name === dockerCase.name);
@@ -771,6 +781,191 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
}
);
/**
* ADOPT an already-running container (`owned: false`). The mirror of the
* remote-SSH attach path: Codeman execs into a container the user built and
* runs, and never creates, starts, stops, restarts or removes it.
*
* Everything here is read-only toward the container. The preflight refuses at
* LINK time — missing, stopped, or no tmux inside — because the alternative is
* failing at session launch, where the only ways out would be a dead pane or
* starting a container we do not own. There is no image gate and no
* `ensureCaseImage`: adoption never runs `docker create`, so the container's
* image is the user's business.
*/
app.post(
'/api/cases/docker-adopt',
async (req, reply): Promise<ApiResponse<{ case: unknown; image?: string; availableModes?: SessionMode[] }>> => {
// ⚠️ Admin-only in multi-user mode, unlike `docker-link` right above. Linking
// creates OUR container, whose only bind mount is a workspace `isWorkingDirAllowed`
// has already confined. Adoption names a container someone else built, and its
// mounts are whatever its owner gave it — a container mounting `/` hands the
// adopter a shell over the whole host, which is exactly the workspace scoping this
// mode exists to enforce. Same machine-level reasoning as the docker HOST routes.
const denied = adminOnly(req, reply);
if (denied) return denied;
const dockerCase = {
...parseBody(DockerCaseAdoptSchema, req.body),
type: 'docker' as const,
owner: ownerFor(req),
owned: false as const,
};
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
const linkedCases = await readLinkedCases();
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
if (
dockerCases.some((item) => item.name === dockerCase.name) ||
linkedCases[dockerCase.name] ||
existsSync(join(resolveCasesDir(getAuthUser(req)), dockerCase.name))
) {
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
}
// Two cases must never share one adopted container: session close kills the
// in-container tmux by session id, but a shared adoption would let one case's
// teardown and another's launch race over the same tmux server.
const container = dockerCase.container;
if (dockerCases.some((item) => (item.container ?? dockerContainerName(item.name)) === container)) {
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `Container "${container}" is already linked to a case`);
}
if (!isWorkingDirAllowed(getAuthUser(req), dockerCase.hostWorkspacePath)) {
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'hostWorkspacePath is outside your workspace');
}
// The workspace must ALREADY exist: it mirrors a path inside a container we
// did not create, so silently mkdir-ing it would invent a host directory that
// does not correspond to whatever is actually mounted there.
if (!existsSync(dockerCase.hostWorkspacePath)) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'hostWorkspacePath does not exist. Adoption mirrors an existing container, so point this at the real host directory already mounted into it.'
);
}
const availability = await checkDockerAvailable(host.engine);
if (!availability.ok) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
availability.error || 'docker daemon is not available'
);
}
// The container workdir is validated INSIDE the container. It defaults to
// hostWorkspacePath only because that is what an owned container's bind
// mount guarantees; adoption mounts nothing, so the probe has to prove it.
const adoptDocker = toSessionDocker(host, dockerCase);
const probe = await probeAdoptableContainer(adoptDocker, dockerAdoptProbeModes(), adoptDocker.containerWorkdir);
if (!probe.ok) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, probe.error || 'container is not adoptable');
}
// Persist what the container actually has: the run-mode picker gates on
// HOST CLIs, which is the wrong question for a case whose agents run inside
// a container the host knows nothing about.
const adoptedCase = { ...dockerCase, availableModes: probe.availableModes };
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, adoptedCase]);
ctx.broadcast(SseEvent.CaseLinked, {
name: adoptedCase.name,
path: adoptedCase.hostWorkspacePath,
type: 'docker',
});
return {
success: true,
data: { case: adoptedCase, image: probe.image, availableModes: probe.availableModes },
};
}
);
/**
* Preflight an existing container WITHOUT linking anything, so the UI can tell
* the user "not running" / "no tmux" / "codex present, claude missing" before
* they commit to a case name. Read-only; never touches container lifecycle.
*/
/**
* Containers on the host's engine, for the adoption picker. Read-only and
* best-effort (mirror of the remote `:hostId/sessions` discovery route): an
* unreachable daemon yields an empty list rather than an error, because the
* container name is a free-text field the user can always type by hand.
*/
app.get(
'/api/docker-hosts/:hostId/containers',
async (req, reply): Promise<ApiResponse<{ containers: DockerContainerInfo[] }>> => {
// Enumerating every container on the engine is machine-level information (names,
// images, uptime), so it follows the docker-host policy rather than the case one.
const denied = adminOnly(req, reply);
if (denied) return denied;
const { hostId } = req.params as { hostId: string };
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
const containers = await listDockerContainers({
engine: host.engine ?? 'docker',
context: host.context,
daemonHost: host.daemonHost,
});
return { success: true, data: { containers } };
}
);
/**
* Browse a directory INSIDE a container, for the adoption form's
* container-workdir picker. The host picker cannot answer this: for an adopted
* container nothing is mounted at a matching host path, so the field would
* otherwise be typed blind. Read-only — one `ls` through `docker exec`.
*/
app.post('/api/docker-cases/browse', async (req, reply): Promise<ApiResponse<DockerBrowseResult>> => {
// Reads a directory listing inside an ARBITRARY named container, so it is gated with
// the adopt flow it serves rather than with the (owner-scoped) case file routes.
const denied = adminOnly(req, reply);
if (denied) return denied;
const body = parseBody(DockerBrowseSchema, req.body);
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === body.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
const result = await browseInContainer(
{
engine: host.engine ?? 'docker',
context: host.context,
daemonHost: host.daemonHost,
containerName: body.container,
},
body.path || '/'
);
return { success: true, data: result };
});
app.post('/api/docker-cases/adopt-preflight', async (req, reply): Promise<ApiResponse<AdoptedContainerProbe>> => {
const body = parseBody(DockerAdoptPreflightSchema, req.body);
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
// ⚠️ NOT plain `adminOnly`, unlike the two routes above: the run menu probes this for
// every docker case to learn which CLIs the CONTAINER has, so an admin-only gate would
// hide every agent mode from a non-admin's own docker case. A non-admin may therefore
// probe a container ALREADY linked to a case they can access — never an arbitrary one,
// which is the adopt-time question and stays admin-only with the rest of that flow.
if (!isAdmin(req)) {
const owns = dockerCases.some(
(item) =>
(item.container ?? dockerContainerName(item.name)) === body.container &&
canAccessOwned(getAuthUser(req), item.owner)
);
if (!owns) {
reply.code(403);
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
}
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === body.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
const probe = await probeAdoptableContainer(
{
engine: host.engine ?? 'docker',
context: host.context,
daemonHost: host.daemonHost,
containerName: body.container,
},
dockerAdoptProbeModes(),
body.containerWorkdir
);
return { success: true, data: probe };
});
// One-click "Run in Docker": create a NORMAL case (folder in CASES_DIR, scaffolded)
// AND link it to a hardened container with default settings, auto-provisioning a
// shared `default` docker host so the user never touches host/image/network fields.
@@ -899,6 +1094,17 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
const sessionDocker = toSessionDocker(host, dockerCase);
// A full export `docker commit`s the container into an image. For an ADOPTED
// container that means packaging someone else's container — with whatever
// credentials its owner logged in with — into a bundle Codeman then hands out,
// and it is the one export step that touches the container at all. The
// workspace-only export is a plain host-directory tar and stays available.
if (mode === 'full' && dockerCase.owned === false) {
return createErrorResponse(
ApiErrorCode.FORBIDDEN,
`Case "${name}" adopted an existing container. Codeman does not own it and will not commit it to an image — use a workspace-only export, or build the image yourself.`
);
}
if (mode === 'full' && !sessionDocker.mountCredentials) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
@@ -1042,8 +1248,22 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
'/api/docker-cases/:name/recreate',
async (req): Promise<ApiResponse<{ name: string; container: string }>> => {
const { name } = req.params as { name: string };
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name);
// Ownership gate: recreate DESTROYS a container, so it must be scoped like
// delete is (`canAccessOwned`). Without it any user could rebuild another
// user's container by name.
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find(
(item) => item.name === name && canAccessOwned(getAuthUser(req), item.owner)
);
if (!dockerCase) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker case not found');
// An ADOPTED container is the user's own: there is nothing to recreate it
// from (no create-config, no image gate) and destroying it is exactly what
// adoption promises never to do.
if (dockerCase.owned === false) {
return createErrorResponse(
ApiErrorCode.FORBIDDEN,
`Case "${name}" adopted an existing container. Codeman does not own its lifecycle and will not recreate it — rebuild it yourself, or unlink the case.`
);
}
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
const sessionDocker = toSessionDocker(host, dockerCase);
@@ -1154,7 +1374,13 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
);
// Best-effort `docker rm -f` the per-case container (case-delete is the
// explicit teardown that removes it; the bind-mounted workspace survives).
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
// An ADOPTED container is skipped entirely: unlinking the case must leave
// the user's own container running and untouched. The seed file is skipped
// with it — adoption never wrote one.
const host =
dockerCase.owned === false
? undefined
: (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
if (host) {
const sessionDocker = toSessionDocker(host, dockerCase);
try {
+64 -4
View File
@@ -38,7 +38,7 @@ import {
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
import { isMultiUserMode, userSpacePath } from '../../config/multiuser.js';
import {
CASES_DIR,
@@ -425,6 +425,40 @@ function getFilesystemPreviewKind(fileName: string): FilesystemPreviewKind | und
return undefined;
}
/**
* Blocked trees, minus any tree that would swallow a configured picker root
* whole.
*
* `/root` is a default blocked tree, and Codeman running as root (containers,
* plenty of servers) makes `homedir()` exactly `/root` — so the picker's own
* allowlisted Home root was blocked by the attachment guard, every other
* candidate lives under it or does not exist, and the endpoint answered 403
* "No filesystem browse roots are available" with no root the user could reach.
*
* Dropping the tree does NOT expose secrets: `isSensitivePath` independently
* matches `.ssh/`, `.env`, `credentials*` and friends at any depth, and it is
* what the directory probe below asks about. Trees with no configured root
* beneath them (`/etc`) are untouched.
*/
function pickerBlockedTrees(blockedTrees: readonly string[], roots: readonly string[]): readonly string[] {
if (roots.length === 0) return blockedTrees;
return blockedTrees.filter((tree) => !roots.some((root) => isUnderTree(root, tree)));
}
/** Resolve candidate roots to realpaths, dropping the ones that do not exist. */
function resolveCandidateRootPaths(candidates: ReadonlyArray<{ path: string }>): string[] {
const out: string[] = [];
for (const candidate of candidates) {
if (!isAbsolute(candidate.path)) continue;
try {
out.push(realpathSync(candidate.path));
} catch {
// Optional roots (for example /mnt/d on non-WSL hosts) are omitted.
}
}
return out;
}
function isBlockedPickerPath(path: string, blockedTrees: readonly string[], directory = false): boolean {
if (isBlockedAttachmentPath(path, blockedTrees)) return true;
// The shared sensitive-path matcher describes file locations such as
@@ -491,13 +525,14 @@ async function resolveFilesystemPickerRoots(
}
const guard = await loadAttachmentGuardConfig();
const trees = pickerBlockedTrees(guard.blockedTrees, resolveCandidateRootPaths(candidates));
const roots: FilesystemBrowseRoot[] = [];
const seen = new Set<string>();
for (const candidate of candidates) {
if (!isAbsolute(candidate.path)) continue;
try {
const resolved = realpathSync(candidate.path);
if (seen.has(resolved) || isBlockedPickerPath(resolved, guard.blockedTrees, true)) continue;
if (seen.has(resolved) || isBlockedPickerPath(resolved, trees, true)) continue;
const stat = await fs.stat(resolved);
if (!stat.isDirectory()) continue;
seen.add(resolved);
@@ -536,8 +571,21 @@ async function resolveFilesystemPickerPath(
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'No filesystem browse roots are available');
}
// With no explicit path (the "Link Existing" case picker, which passes no
// sessionId and an empty initialPath until the user has typed something),
// land on the shared cases root rather than falling through to whichever
// root happens to be first. `Codeman Cases` sits inside `Home` only on the
// native default (~/codeman-cases); a Docker deployment binds them at
// unrelated host paths (CODEMAN_APPDATA_PATH vs CODEMAN_CASES_PATH), so a
// Home-first fallback opened the picker somewhere with no cases in sight —
// and, worse, made an OLD case folder left behind by a since-changed
// CODEMAN_CASES_PATH look like a normal thing to stumble across while
// browsing for one to link.
const fallbackRoot =
roots.find((root) => root.label === 'Current Folder') ?? roots.find((root) => root.path === '/mnt/d') ?? roots[0];
roots.find((root) => root.label === 'Current Folder') ??
roots.find((root) => root.label === 'Codeman Cases') ??
roots.find((root) => root.path === '/mnt/d') ??
roots[0];
const candidatePath = resolve(requestedPath ?? fallbackRoot.path);
let resolvedPath: string;
@@ -556,7 +604,19 @@ async function resolveFilesystemPickerPath(
}
const guard = await loadAttachmentGuardConfig();
return { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees: guard.blockedTrees };
// Navigation must use the SAME narrowed list the roots were selected with.
// Handing the raw trees down here would admit a root and then refuse every
// path inside it, which reads as a picker that opens and then does nothing.
return {
candidatePath,
resolvedPath,
roots,
matchingRoot,
blockedTrees: pickerBlockedTrees(
guard.blockedTrees,
roots.map((root) => root.path)
),
};
}
function appendDownloadFlag(url: string): string {
+31 -3
View File
@@ -126,10 +126,34 @@ export function registerHookEventRoutes(
// Sync Claude's current conversation id. Interactive PTY mode never emits
// `session_id` on stdout, so hooks are the only reliable way to learn that
// the user ran `/clear` (which spins up a new conversation jsonl).
let conversationChanged = false;
if (data && typeof data.session_id === 'string' && data.session_id) {
const session = ctx.sessions.get(sessionId);
const prevClaudeSessionId = session?.claudeSessionId;
session?.adoptClaudeSessionId(data.session_id);
const prevChainLength = session?.claudeSessionChain.length ?? 0;
// FIRST-HAND: this payload came from the CLI process itself and reached us
// because the pane's own $CODEMAN_SESSION_ID addressed it. No cwd, no
// timestamp, nothing a sibling pane on the same folder could win — so the
// response viewer can stop guessing entirely (see
// resolveActiveClaudeSessionIdFromHistory).
session?.adoptClaudeSessionId(data.session_id, { firstHand: true });
if (event === 'prompt_submitted') {
// Repairs `lastSubmitAt` for a pane driven straight from tmux: it was
// bumped only by input that flowed through Codeman's own write path, so
// it read 0 forever for those panes and every consumer of "when did this
// pane last submit" silently degraded.
session?.markPromptSubmitted();
}
// Persist when the conversation actually moved: `/clear` emits no
// completion event, so without this the successor id is lost on restart
// and recovery falls back to the launch conversation.
if (
session &&
(session.claudeSessionId !== prevClaudeSessionId || session.claudeSessionChain.length !== prevChainLength)
) {
conversationChanged = true;
ctx.persistSessionState(session);
}
// Docker sessions: keep the case's resume seed following the LIVE
// conversation (post-/clear id switches), so a container stop/reboot
// relaunch resumes the right transcript.
@@ -207,9 +231,13 @@ export function registerHookEventRoutes(
...(approvalId && session?.mode !== 'deepseek' && { approvalId }),
});
// Track in run summary
// Track in run summary. `prompt_submitted` fires on EVERY prompt of every
// Claude pane; only the ones where the conversation actually moved (a /clear
// successor) carry information, and recording the rest would push a row into
// the Summary timeline and /api/search per turn and evict useful rows from
// the 1000-event FIFO (#367 merge-time fix).
const summaryTracker = ctx.runSummaryTrackers.get(sessionId);
if (summaryTracker) {
if (summaryTracker && (event !== 'prompt_submitted' || conversationChanged)) {
summaryTracker.recordHookEvent(event, safeData);
}
+128 -52
View File
@@ -134,6 +134,7 @@ import {
import {
checkDockerAvailable,
checkDockerConfigDrift,
probeAdoptableContainer,
checkDockerTmuxAvailable,
ensureAgentBaseImage,
DEFAULT_AGENT_IMAGE,
@@ -1840,8 +1841,17 @@ export function registerSessionRoutes(
session: Session,
projectsDir: string
): Promise<string | null> {
// A pane whose conversation id came from its OWN hook needs no correlation:
// $CODEMAN_SESSION_ID (the pane's env) -> data.session_id (the CLI's own
// stdin JSON) is a first-hand binding that never looks at cwd, so it cannot
// be stolen by a sibling pane, a closed tab, or a bare `claude` in a
// terminal. Guessing can only be worse than the fact. This is also what
// closes the hole below for a pane driven straight from tmux: it never
// reaches `if (!submitAt)`.
if (session.claudeSessionIdIsFirstHand) return null;
const submitAt = session.lastSubmitAt;
if (!submitAt) return null; // never typed through Codeman — nothing to credit
if (!submitAt) return null; // no anchor at all — nothing to credit
const cached = claudeHistoryPinCache.get(session.id);
if (cached && cached.submitAt === submitAt) return cached.claudeSessionId;
@@ -1906,9 +1916,13 @@ export function registerSessionRoutes(
}
interface ClaudeResponseMessage {
kind: 'prompt' | 'response';
label: 'Prompt' | 'Response';
role: 'user' | 'assistant';
text: string;
timestamp?: string;
turn: number;
queued?: boolean;
}
interface ClaudeTranscriptEntry {
@@ -1918,6 +1932,19 @@ export function registerSessionRoutes(
isSidechain?: boolean;
isCompactSummary?: boolean;
message?: { content?: unknown };
// A prompt typed while Claude is working is absorbed mid-turn and recorded
// ONLY here — the CLI never re-emits it as a `user` row. Every field stays
// optional and unvalidated: `queued_command` is not a documented CLI
// contract, so a missing/renamed field must mean "skip", which is also what
// the CLI's own non-human queue entries (commandMode 'task-notification',
// no `origin` key) require. Shape observed on Claude Code 2.1.220-2.1.251.
attachment?: {
type?: string;
prompt?: string;
commandMode?: string;
timestamp?: string;
origin?: { kind?: string };
};
}
function extractClaudeText(content: unknown, separator: string): string {
@@ -1943,10 +1970,17 @@ export function registerSessionRoutes(
}
/**
* 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.
* Claude writes an append-only event log: tool results arrive as user rows,
* thinking/tool_use rows carry no text, and a prompt typed while the agent is
* working is only ever recorded as an `attachment/queued_command` row. But one
* assistant row IS one whole model message: measured across ~/.claude/projects
* (CLI 2.1.220-2.1.251) no assistant row carries more than one content block
* and no message id carries more than one text block, so there is nothing to
* reassemble. Emit one card per row and group them with `turn` instead of
* concatenating a human turn's replies into a single card (#169), which fused
* up to 74 distinct model messages into one card. Splitting is safe for
* markdown: no adjacent pair of assistant text rows in the corpus continues a
* table, a list, or an open code fence.
*/
function parseClaudeResponseTranscript(
content: string,
@@ -1955,8 +1989,23 @@ export function registerSessionRoutes(
let lastText = '';
let lastTimestamp = '';
const messages: ClaudeResponseMessage[] = [];
let currentUserFragments = new Set<string>();
let currentAssistantFragments = new Set<string>();
// #169's replay guards, kept: they now SKIP a duplicated row instead of
// concatenating it into the previous card.
const currentUserFragments = new Set<string>();
const currentAssistantFragments = new Set<string>();
// Turn 0 is reserved for anything emitted before the first human prompt.
let turn = 0;
const pushUserMessage = (text: string, timestamp: string | undefined, queued: boolean): void => {
// A run of consecutive human inputs (a mid-turn queued burst) is ONE turn,
// so the viewer renders it under one badge instead of one badge per line.
if (messages.at(-1)?.role !== 'user') turn += 1;
const message: ClaudeResponseMessage = { kind: 'prompt', label: 'Prompt', role: 'user', text, timestamp, turn };
if (queued) message.queued = true;
messages.push(message);
currentUserFragments.add(text);
currentAssistantFragments.clear();
};
for (const line of content.split('\n')) {
if (!line) continue;
@@ -1970,25 +2019,37 @@ export function registerSessionRoutes(
// rows include repeated image dimensions and other UI-generated context.
if (entry.isSidechain) continue;
// A prompt typed while Claude is working is absorbed mid-turn and lives
// ONLY in an attachment row, so it was lost outright. `origin.kind` and
// `commandMode` separate the human's queue entries from the CLI's own:
// measured over 57 real transcripts on 2026-09-01, 322 queued_command rows
// split 163 `prompt`/`human` and 159 `task-notification`, and not one of
// those 159 carries an `origin` key. The 163 human rows become 162 user
// cards here — one is a verbatim repeat inside a still-unanswered user run
// and is collapsed by the dedup guard below — out of 353 user cards total.
if (entry.type === 'attachment') {
if (!full) continue;
const queued = entry.attachment;
if (!queued || queued.type !== 'queued_command') continue;
if (queued.origin?.kind !== 'human' || queued.commandMode !== 'prompt') continue;
const text = typeof queued.prompt === 'string' ? queued.prompt.trim() : '';
if (!text || isClaudeSyntheticUserMessage(entry, text)) continue;
if (currentUserFragments.has(text)) continue;
pushUserMessage(text, queued.timestamp || entry.timestamp, true);
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();
// Claude replays the initial user row while restoring a transcript, and
// a CLI that also wrote an absorbed prompt as a user row would double it.
// Both collapse here. The same prompt sent again AFTER a reply is a
// legitimate second turn, because that reply cleared the set.
if (currentUserFragments.has(text)) continue;
pushUserMessage(text, entry.timestamp, false);
continue;
}
@@ -1999,18 +2060,10 @@ export function registerSessionRoutes(
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]);
}
// Replayed snapshots repeat an identical text block inside one turn.
if (currentAssistantFragments.has(text)) continue;
messages.push({ kind: 'response', label: 'Response', role: 'assistant', text, timestamp: entry.timestamp, turn });
currentAssistantFragments.add(text);
currentUserFragments.clear();
}
@@ -3020,25 +3073,48 @@ export function registerSessionRoutes(
);
}
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');
// An ADOPTED container skips every image-side gate: we never run `docker
// create`, so the image is the user's business, and `ensureAgentBaseImage`
// would build/require an image that has nothing to do with their container.
// The prerequisite that DOES still hold is tmux inside it, so probe the live
// container (not the image) and refuse before launch rather than dead-paning.
if (sessionDocker.owned === false) {
const probe = await probeAdoptableContainer(sessionDocker, [mode]);
if (!probe.ok) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, probe.error || 'container is not usable');
}
// The probe already exec'd into the container; carry its facts onto the
// live session so the launch chain does not have to re-ask.
sessionDocker.runsAsRoot = probe.runsAsRoot;
// No `mode !== 'shell'` arm: a mode with no binary of its own is reported
// available by the probe unconditionally, so this reads the same answer for it.
if (!probe.availableModes?.includes(mode)) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
`"${mode}" is not installed in container "${sessionDocker.containerName}". Adoption never modifies the container — install it inside, or pick another mode.`
);
}
} else {
// 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');
}
}
}
+37 -21
View File
@@ -102,14 +102,14 @@ function withWebviews<T>(fn: (list: Webview[]) => Promise<T> | T): Promise<T> {
return next;
}
export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort): void {
registerCrudRoutes(app, ctx);
registerProxyRoutes(app);
export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort, basePath = ''): void {
registerCrudRoutes(app, ctx, basePath);
registerProxyRoutes(app, basePath);
}
// ───────────────────────────── CRUD ─────────────────────────────
function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort): void {
function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort, basePath: string): void {
app.get('/api/webviews', async (req) => {
const user = getAuthUser(req);
const all = await readWebviews(configDir());
@@ -279,7 +279,7 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort
}
const capability = webviewCapabilities.mint(webview.id, webview.owner);
const data: WebviewOpenData = { webview, embedUrl: proxyPrefixFor(capability) };
const data: WebviewOpenData = { webview, embedUrl: proxyPrefixFor(capability, basePath) };
return { success: true, data };
});
}
@@ -347,7 +347,7 @@ async function probeUrl(url: string): Promise<WebviewProbe> {
// ───────────────────────────── Proxy ─────────────────────────────
function registerProxyRoutes(app: FastifyInstance): void {
function registerProxyRoutes(app: FastifyInstance, basePath: string): void {
app.register(async (scope) => {
// Encapsulated to this plugin only. The proxy must relay request bodies
// BYTE-FOR-BYTE, so every parser is replaced with a pass-through that hands
@@ -358,11 +358,11 @@ function registerProxyRoutes(app: FastifyInstance): void {
// A single GET route serving both roles: `handler` for normal requests,
// `wsHandler` for upgrades. Registering them as two routes on one URL would
// collide.
// collide. (The WS leg produces no browser-facing URLs, so it needs no base.)
scope.route<{ Params: ProxyParams }>({
method: 'GET',
url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`,
handler: proxyHttp,
handler: (req, reply) => proxyHttp(req, reply, basePath),
wsHandler: proxyWebSocket,
});
@@ -371,14 +371,14 @@ function registerProxyRoutes(app: FastifyInstance): void {
scope.route<{ Params: ProxyParams }>({
method: ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`,
handler: proxyHttp,
handler: (req, reply) => proxyHttp(req, reply, basePath),
});
// `/webview/<cap>` with no trailing slash: redirect rather than serve, so the
// browser's notion of the base path ends in `/` and relative URLs in the
// dashboard's HTML resolve inside the prefix instead of one level above it.
scope.get<{ Params: { cap: string } }>(`${WEBVIEW_PROXY_PREFIX}/:cap`, (req, reply) => {
return reply.redirect(proxyPrefixFor(req.params.cap), 302);
return reply.redirect(proxyPrefixFor(req.params.cap, basePath), 302);
});
});
}
@@ -406,8 +406,12 @@ async function lookupCapability(capability: string): Promise<Webview | null> {
* streamed asset comes back zero-length. Returning the reply is what tells Fastify
* the response is already owned by this handler.
*/
function proxyHttp(req: FastifyRequest<{ Params: ProxyParams }>, reply: FastifyReply): Promise<FastifyReply> {
return proxyRequest(req, reply, req.params.cap, req.params['*'] ?? '');
function proxyHttp(
req: FastifyRequest<{ Params: ProxyParams }>,
reply: FastifyReply,
basePath: string
): Promise<FastifyReply> {
return proxyRequest(req, reply, req.params.cap, req.params['*'] ?? '', basePath);
}
/**
@@ -419,7 +423,8 @@ async function proxyRequest(
req: FastifyRequest,
reply: FastifyReply,
cap: string,
wildcard: string
wildcard: string,
basePath = ''
): Promise<FastifyReply> {
const webview = await lookupCapability(cap);
if (!webview) {
@@ -454,7 +459,8 @@ async function proxyRequest(
const headers = buildUpstreamRequestHeaders(req.headers, upstream, {
forwardCookies: webview.trusted,
sessionCookieName: AUTH_COOKIE_NAME,
refererPath: typeof req.headers.referer === 'string' ? stripProxyPrefix(req.headers.referer, cap) : undefined,
refererPath:
typeof req.headers.referer === 'string' ? stripProxyPrefix(req.headers.referer, cap, basePath) : undefined,
});
// #237: the timeout bounds TIME-TO-HEADERS only. A plain AbortSignal.timeout on
@@ -546,7 +552,8 @@ async function proxyRequest(
response.headers.getSetCookie(),
cap,
upstream,
secureContext
secureContext,
basePath
);
reply.code(response.status);
@@ -575,7 +582,7 @@ async function proxyRequest(
// Buffer only HTML, only under the cap: `<base>` injection needs the whole
// document, and buffering an unbounded upstream body is a memory hazard.
const html = await response.text();
return reply.send(html.length <= MAX_WEBVIEW_HTML_REWRITE_BYTES ? rewriteHtml(html, cap) : html);
return reply.send(html.length <= MAX_WEBVIEW_HTML_REWRITE_BYTES ? rewriteHtml(html, cap, basePath) : html);
}
return reply.send(Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]));
@@ -596,25 +603,34 @@ async function proxyRequest(
*
* @returns true when the request was handled (caller must not also reply).
*/
export async function tryWebviewRefererFallback(req: FastifyRequest, reply: FastifyReply): Promise<boolean> {
export async function tryWebviewRefererFallback(
req: FastifyRequest,
reply: FastifyReply,
basePath = ''
): Promise<boolean> {
// Safe methods only. A write arriving here has already lost its raw body to the
// root instance's JSON parser, so it could not be relayed faithfully anyway.
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
const capability = capabilityFromReferer(typeof req.headers.referer === 'string' ? req.headers.referer : undefined);
const capability = capabilityFromReferer(
typeof req.headers.referer === 'string' ? req.headers.referer : undefined,
basePath
);
if (!capability) return false;
if (!webviewCapabilities.resolve(capability)) return false;
// req.url is already base-stripped by the server's rewriteUrl, so this is the
// internal path the upstream resolver expects.
const path = req.url.split('?')[0].replace(/^\//, '');
await proxyRequest(req, reply, capability, path);
await proxyRequest(req, reply, capability, path, basePath);
return true;
}
/** Turn a proxy-side Referer back into the upstream path it corresponds to. */
function stripProxyPrefix(referer: string, capability: string): string | undefined {
function stripProxyPrefix(referer: string, capability: string, basePath = ''): string | undefined {
try {
const url = new URL(referer);
const prefix = proxyPrefixFor(capability);
const prefix = proxyPrefixFor(capability, basePath);
if (!url.pathname.startsWith(prefix)) return undefined;
return `/${url.pathname.slice(prefix.length)}${url.search}`;
} catch {