mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
fix(session): degrade the resume pin to the session id, not to nothing
A single pin that failed its transcript gate returned the options untouched, so `resumeSessionId` fell back to `_resumeSessionId` — undefined for an ordinary session — and the renderer emitted the bare `claude --dangerously-skip-permissions --session-id "<this.id>"`. Every session prompted before its first `/clear` owns a transcript under that id, so the dropped pin handed back exactly the refusal this branch removes, with no `||` branch to catch it. It was also a regression against master on the `restartCli()` path, which pinned `_claudeSessionId ?? this.id` and, since the constructor seeds that field, could never land unpinned. The pin now walks three candidates in priority order — the conversation chain's tail, the launch seed, then the session's own id — and takes the first one a transcript backs. A candidate that misses is passed over rather than ending the walk. Falling off the end pins nothing, which also settles the second half of the problem: the old code skipped the transcript check whenever the pin was the session's own id, so a genuinely new pane rendered the two-branch form after all. That costs a brand-new session claude's "No conversation found" line in its scrollback, and `wrapWithNice()` prefixes only the first branch of the rendered `a || b`, so the branch that actually runs loses its priority for the life of the session. With no transcript anywhere the bare `--session-id` is the correct command, so the comment claiming an unchanged shape is now true. The transcript lookup reads the server process's own `CLAUDE_CONFIG_DIR` when a session declares none. A pane inherits the server environment through tmux, so on an install that exports it the CLI writes its transcripts there and every lookup under `~/.claude` was a false negative — which under the old code meant the colliding command. `claudeCredentialsPath()` and `realClaudeConfigDir()` resolve the same directory the same way. The header sentence calling a skipped resume "the safe direction" described the opposite of what happens at this call site, and says so now. The create-path fallback writes `_resumeSessionId` alongside the create options. That branch leaves `isRestored` false, so `_claudeSessionId` is recomputed from the launch fields and settled on `this.id` while the CLI resumed the chain tail; the response viewer, Read My Mind and the unified-list alias map read that field until the next first-hand hook. Four new tests: a chain tail with no transcript while the session id has one, no transcript anywhere, the create path's alias, and the process-env lookup. All four fail against the previous commit. Two existing tests move with the gate — the guess-refusal test now backs the session's own id, and the custom-model restart test gives its working pane the transcript that makes `--session-id` collide in the first place, alongside a new one pinning the no-transcript case. CLAUDE.md described the pin as a `restartCli()`-only thing sourced from the live conversation id. All three halves of that moved here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
47e7935274
commit
1cb0441bd8
@@ -12,7 +12,8 @@
|
||||
* 2. Applying to a local claude session killed the pane: the relaunch was
|
||||
* `claude --session-id <id>` and Claude refuses an id that already has a
|
||||
* transcript, so it needs the `--resume <id> || --session-id <id>` shape the
|
||||
* docker and remote pane commands use, i.e. a pinned resume id.
|
||||
* docker and remote pane commands use, i.e. a pinned resume id. The pin is
|
||||
* gated on that transcript existing, so these tests write one.
|
||||
* 3. pi/omp/grok wrote their config file and then launched without the `--model`
|
||||
* that selects it, so the file was ignored.
|
||||
*
|
||||
@@ -20,7 +21,7 @@
|
||||
* spying on `respawnPane` to read the options the relaunch would get.
|
||||
* Port: N/A.
|
||||
*/
|
||||
import { mkdirSync, rmSync } from 'node:fs';
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -30,13 +31,29 @@ import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import type { MuxSession, SessionMode } from '../src/types.js';
|
||||
|
||||
const workingDir = join(homedir(), 'codeman-cases', 'custom-model-restart');
|
||||
const projectsDir = join(homedir(), '.claude', 'projects', '-custom-model-restart');
|
||||
const sessions: Session[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const s of sessions.splice(0)) s.stop();
|
||||
rmSync(workingDir, { recursive: true, force: true });
|
||||
// Only the directory these tests create. `setup.ts` gives each test file a
|
||||
// temp HOME, but a wider sweep here would delete a real `~/.claude` the day
|
||||
// that stops being true.
|
||||
rmSync(projectsDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Write the transcript Claude would have written for a conversation. A pane
|
||||
* `restartCli()` relaunches is a WORKING one, so its conversation has a
|
||||
* transcript on disk; that is both what makes the bare `--session-id` collide
|
||||
* and what the pin is now gated on.
|
||||
*/
|
||||
function giveTranscript(conversationId: string): void {
|
||||
mkdirSync(projectsDir, { recursive: true });
|
||||
writeFileSync(join(projectsDir, `${conversationId}.jsonl`), '{"type":"user"}\n');
|
||||
}
|
||||
|
||||
function liveSession(mode: SessionMode, extra: Record<string, unknown> = {}) {
|
||||
mkdirSync(workingDir, { recursive: true });
|
||||
const mux = new TmuxManager();
|
||||
@@ -118,6 +135,7 @@ describe('clearing a selection unsets what it injected', () => {
|
||||
describe('restartCli() must not kill a working pane', () => {
|
||||
it('claude: pins the live conversation id so the relaunch renders --resume <id> || --session-id <id>', async () => {
|
||||
const { session, respawn } = liveSession('claude');
|
||||
giveTranscript(session.id);
|
||||
await session.restartCli();
|
||||
const options = respawn.mock.calls[0][0];
|
||||
expect(options.resumeSessionId).toBe(session.claudeSessionId);
|
||||
@@ -126,7 +144,33 @@ describe('restartCli() must not kill a working pane', () => {
|
||||
expect(session.toState().resumeSessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('claude: pins nothing when the pane has no transcript, because nothing can collide', async () => {
|
||||
// A pane that was launched and never prompted. `--session-id <this.id>` is
|
||||
// accepted on an id no transcript holds, so pinning would buy nothing and
|
||||
// cost two things: claude prints "No conversation found" into a pane with
|
||||
// no history, and `wrapWithNice()` prefixes only the first branch of the
|
||||
// rendered `a || b`, so the branch that actually runs loses its priority
|
||||
// for the life of the session.
|
||||
const { session, respawn } = liveSession('claude');
|
||||
await session.restartCli();
|
||||
expect(respawn.mock.calls[0][0].resumeSessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('claude: an explicit resume id from a resume-from-history launch wins over the pin', async () => {
|
||||
const RESUMED = '01a060f0-0361-7f91-abde-b283020db0d7';
|
||||
const { session, respawn } = liveSession('claude', { resumeSessionId: RESUMED });
|
||||
giveTranscript(RESUMED);
|
||||
await session.restartCli();
|
||||
expect(respawn.mock.calls[0][0].resumeSessionId).toBe(RESUMED);
|
||||
});
|
||||
|
||||
it('claude: a launch seed survives the pin walk even with its transcript gone', async () => {
|
||||
// The walk only ever ADDS a pin. The seed is what the session was created
|
||||
// with and every respawn has always carried it, so a transcript deleted
|
||||
// under a running session leaves the relaunch on
|
||||
// `--resume <seed> || --session-id <this.id>` — the resume fails and the
|
||||
// fallback runs, which is safe precisely because nothing is on disk to
|
||||
// collide with.
|
||||
const RESUMED = '01a060f0-0361-7f91-abde-b283020db0d7';
|
||||
const { session, respawn } = liveSession('claude', { resumeSessionId: RESUMED });
|
||||
await session.restartCli();
|
||||
|
||||
Reference in New Issue
Block a user