mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
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:
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user