Skip to content

Commit ec4ac4a

Browse files
authored
Preserve Claude chats when changing project paths (#403)
* server: make project path updates transactional * claude: relocate sessions during project path updates * test(integration): cover Claude project path relocation * fix(claude): harden relocation cleanup and process exit * refactor: isolate project path update coordination * refactor: keep project path errors domain-neutral
1 parent 69340c1 commit ec4ac4a

22 files changed

Lines changed: 1721 additions & 62 deletions
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { randomUUID } from 'node:crypto';
2+
import {
3+
accessSync,
4+
appendFileSync,
5+
mkdirSync,
6+
writeFileSync,
7+
} from 'node:fs';
8+
import { homedir } from 'node:os';
9+
import { join, resolve } from 'node:path';
10+
11+
const MAX_SANITIZED_LENGTH = 200;
12+
const args = process.argv.slice(2);
13+
14+
if (args.includes('--version')) {
15+
console.log('2.1.220 (Claude Code)');
16+
} else if (args[0] === 'auth' && args[1] === 'status') {
17+
console.log(JSON.stringify({ loggedIn: true, authMethod: 'api_key' }));
18+
} else {
19+
await runStreamSession();
20+
}
21+
22+
async function runStreamSession(): Promise<void> {
23+
const sessionId = argumentValue('--session-id') ?? argumentValue('--resume');
24+
if (!sessionId) {
25+
writeOutput({ type: 'system', subtype: 'init', slash_commands: [] });
26+
return;
27+
}
28+
29+
const nativePath = claudeNativePath(process.cwd(), sessionId);
30+
const isResume = argumentValue('--resume') !== null;
31+
if (isResume) {
32+
try {
33+
accessSync(nativePath);
34+
} catch {
35+
console.error(`No conversation found with session ID: ${sessionId}`);
36+
process.exitCode = 1;
37+
return;
38+
}
39+
} else {
40+
initializeSessionArtifacts(nativePath, sessionId);
41+
}
42+
43+
writeOutput({
44+
type: 'system',
45+
subtype: 'init',
46+
session_id: sessionId,
47+
model: argumentValue('--model') ?? 'claude-haiku-4-5-20251001',
48+
slash_commands: [],
49+
});
50+
51+
const decoder = new TextDecoder();
52+
let buffered = '';
53+
for await (const chunk of Bun.stdin.stream()) {
54+
buffered += decoder.decode(chunk, { stream: true });
55+
let newline = buffered.indexOf('\n');
56+
while (newline >= 0) {
57+
handleInput(buffered.slice(0, newline), nativePath, sessionId);
58+
buffered = buffered.slice(newline + 1);
59+
newline = buffered.indexOf('\n');
60+
}
61+
}
62+
buffered += decoder.decode();
63+
if (buffered.trim()) handleInput(buffered, nativePath, sessionId);
64+
}
65+
66+
function argumentValue(name: string): string | null {
67+
const index = args.indexOf(name);
68+
return index >= 0 && typeof args[index + 1] === 'string'
69+
? args[index + 1]!
70+
: null;
71+
}
72+
73+
function initializeSessionArtifacts(nativePath: string, sessionId: string): void {
74+
const projectDirectory = resolve(nativePath, '..');
75+
mkdirSync(join(projectDirectory, sessionId, 'subagents'), { recursive: true });
76+
writeFileSync(nativePath, '');
77+
writeFileSync(join(projectDirectory, `${sessionId}.queue.json`), '{"queued":[]}\n');
78+
writeFileSync(
79+
join(projectDirectory, sessionId, 'subagents', 'agent-integration.jsonl'),
80+
`${JSON.stringify({ sessionId, type: 'summary', summary: 'integration support artifact' })}\n`,
81+
);
82+
}
83+
84+
function handleInput(line: string, nativePath: string, sessionId: string): void {
85+
if (!line.trim()) return;
86+
const input = JSON.parse(line) as {
87+
type?: string;
88+
message?: { role?: string; content?: unknown };
89+
};
90+
if (input.type !== 'user' || input.message?.role !== 'user') return;
91+
92+
const prompt = messageText(input.message.content);
93+
const response = `echo:${prompt}`;
94+
const userTimestamp = new Date().toISOString();
95+
const assistantTimestamp = new Date(Date.now() + 1).toISOString();
96+
appendFileSync(nativePath, [
97+
JSON.stringify({
98+
sessionId,
99+
type: 'user',
100+
uuid: randomUUID(),
101+
timestamp: userTimestamp,
102+
cwd: process.cwd(),
103+
message: { role: 'user', content: prompt },
104+
}),
105+
JSON.stringify({
106+
sessionId,
107+
type: 'assistant',
108+
uuid: randomUUID(),
109+
timestamp: assistantTimestamp,
110+
cwd: process.cwd(),
111+
message: {
112+
role: 'assistant',
113+
content: [{ type: 'text', text: response }],
114+
},
115+
}),
116+
'',
117+
].join('\n'));
118+
119+
writeOutput({
120+
type: 'assistant',
121+
session_id: sessionId,
122+
message: {
123+
role: 'assistant',
124+
content: [{ type: 'text', text: response }],
125+
},
126+
});
127+
writeOutput({
128+
type: 'result',
129+
subtype: 'success',
130+
session_id: sessionId,
131+
is_error: false,
132+
duration_ms: 1,
133+
num_turns: 1,
134+
result: response,
135+
});
136+
}
137+
138+
function messageText(content: unknown): string {
139+
if (typeof content === 'string') return content;
140+
if (!Array.isArray(content)) return '';
141+
return content
142+
.flatMap((part) => (
143+
part
144+
&& typeof part === 'object'
145+
&& !Array.isArray(part)
146+
&& (part as { type?: unknown }).type === 'text'
147+
&& typeof (part as { text?: unknown }).text === 'string'
148+
? [(part as { text: string }).text]
149+
: []
150+
))
151+
.join('\n');
152+
}
153+
154+
function claudeNativePath(projectPath: string, sessionId: string): string {
155+
const configHome = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude');
156+
const canonicalProjectPath = resolve(projectPath).normalize('NFC');
157+
const sanitized = canonicalProjectPath.replace(/[^a-zA-Z0-9]/g, '-');
158+
const projectKey = sanitized.length <= MAX_SANITIZED_LENGTH
159+
? sanitized
160+
: `${sanitized.slice(0, MAX_SANITIZED_LENGTH)}-${simpleHash(canonicalProjectPath)}`;
161+
return join(configHome.normalize('NFC'), 'projects', projectKey, `${sessionId}.jsonl`);
162+
}
163+
164+
function simpleHash(value: string): string {
165+
let hash = 0;
166+
for (let index = 0; index < value.length; index += 1) {
167+
hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0;
168+
}
169+
return Math.abs(hash).toString(36);
170+
}
171+
172+
function writeOutput(value: unknown): void {
173+
console.log(JSON.stringify(value));
174+
}

integration-tests/support/garcon-client.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import type {
2626
QueueEntryMoveCommandRequest,
2727
QueueEntryReplaceCommandRequest,
2828
QueueMutationResponse,
29+
ProjectPathPatchRequest,
30+
ProjectPathPatchResponse,
2931
StartChatCommandRequest,
3032
StartChatCommandResponse,
3133
} from '../../common/chat-command-contracts.js';
@@ -501,6 +503,10 @@ export class GarconTestClient {
501503
return this.patch<AgentModelPatchResponse>('/api/v1/chats/agent-model', request);
502504
}
503505

506+
updateProjectPath(request: ProjectPathPatchRequest): Promise<ProjectPathPatchResponse> {
507+
return this.patch<ProjectPathPatchResponse>('/api/v1/chats/project-path', request);
508+
}
509+
504510
deleteChat(chatId: string): Promise<{ success: boolean }> {
505511
return this.delete<{ success: boolean }>('/api/v1/chats', { chatId });
506512
}

0 commit comments

Comments
 (0)