Skip to content
This repository was archived by the owner on Feb 14, 2026. It is now read-only.

Commit 45e32f1

Browse files
cruzanstxclaudehappy-otter
committed
fix(cli): add session path tracking and improve error serialization
When Task subagents create sessions in different directories (e.g., worktrees), the session validation now uses the path where the session was created rather than the current working directory. This fixes "Process exited unexpectedly" errors when mobile users send messages during parallel prompt execution. Also fixes error logging - Error objects were being logged as `{}` because JSON.stringify doesn't capture non-enumerable Error properties. Changes: - Add SessionInfo interface with id and path fields - Update Session class to track session creation path - Modify claudeCheckSession to accept optional sessionPath - Extract cwd from SDKSystemMessage for session tracking - Logger now properly serializes Error objects with name/message/stack - Added getLaunchErrorInfo helper for structured error logging 🤖 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
1 parent f9cb121 commit 45e32f1

5 files changed

Lines changed: 106 additions & 22 deletions

File tree

src/claude/claudeRemote.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export async function claudeRemote(opts: {
1616
// Fixed parameters
1717
sessionId: string | null,
1818
path: string,
19+
sessionPath?: string,
1920
mcpServers?: Record<string, any>,
2021
claudeEnvVars?: Record<string, string>,
2122
claudeArgs?: string[],
@@ -31,7 +32,7 @@ export async function claudeRemote(opts: {
3132
isAborted: (toolCallId: string) => boolean,
3233

3334
// Callbacks
34-
onSessionFound: (id: string) => void,
35+
onSessionFound: (id: string, sessionPath?: string) => void,
3536
onThinkingChange?: (thinking: boolean) => void,
3637
onMessage: (message: SDKMessage) => void,
3738
onCompletionEvent?: (message: string) => void,
@@ -40,7 +41,7 @@ export async function claudeRemote(opts: {
4041

4142
// Check if session is valid
4243
let startFrom = opts.sessionId;
43-
if (opts.sessionId && !claudeCheckSession(opts.sessionId, opts.path)) {
44+
if (opts.sessionId && !claudeCheckSession(opts.sessionId, opts.path, opts.sessionPath)) {
4445
startFrom = null;
4546
}
4647

@@ -178,10 +179,11 @@ export async function claudeRemote(opts: {
178179
// Start a watcher for to detect the session id
179180
if (systemInit.session_id) {
180181
logger.debug(`[claudeRemote] Waiting for session file to be written to disk: ${systemInit.session_id}`);
181-
const projectDir = getProjectPath(opts.path);
182+
const sessionCwd = systemInit.cwd ?? opts.path;
183+
const projectDir = getProjectPath(sessionCwd);
182184
const found = await awaitFileExist(join(projectDir, `${systemInit.session_id}.jsonl`));
183185
logger.debug(`[claudeRemote] Session file found: ${systemInit.session_id} ${found}`);
184-
opts.onSessionFound(systemInit.session_id);
186+
opts.onSessionFound(systemInit.session_id, sessionCwd);
185187
}
186188
}
187189

@@ -235,4 +237,4 @@ export async function claudeRemote(opts: {
235237
} finally {
236238
updateThinking(false);
237239
}
238-
}
240+
}

src/claude/claudeRemoteLauncher.ts

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import React from "react";
66
import { claudeRemote } from "./claudeRemote";
77
import { PermissionHandler } from "./utils/permissionHandler";
88
import { Future } from "@/utils/future";
9-
import { SDKAssistantMessage, SDKMessage, SDKUserMessage } from "./sdk";
9+
import { AbortError, SDKAssistantMessage, SDKMessage, SDKUserMessage } from "./sdk";
1010
import { formatClaudeMessageForInk } from "@/ui/messageFormatterInk";
1111
import { logger } from "@/ui/logger";
1212
import { SDKToLogConverter } from "./utils/sdkToLogConverter";
@@ -23,6 +23,50 @@ interface PermissionsField {
2323
allowedTools?: string[];
2424
}
2525

26+
type LaunchErrorInfo = {
27+
asString: string;
28+
name?: string;
29+
message?: string;
30+
code?: string;
31+
stack?: string;
32+
};
33+
34+
function getLaunchErrorInfo(e: unknown): LaunchErrorInfo {
35+
let asString = '[unprintable error]';
36+
try {
37+
asString = typeof e === 'string' ? e : String(e);
38+
} catch {
39+
// Ignore
40+
}
41+
42+
if (!e || typeof e !== 'object') {
43+
return { asString };
44+
}
45+
46+
const err = e as { name?: unknown; message?: unknown; code?: unknown; stack?: unknown };
47+
48+
const name = typeof err.name === 'string' ? err.name : undefined;
49+
const message = typeof err.message === 'string' ? err.message : undefined;
50+
const code = typeof err.code === 'string' || typeof err.code === 'number' ? String(err.code) : undefined;
51+
const stack = typeof err.stack === 'string' ? err.stack : undefined;
52+
53+
return { asString, name, message, code, stack };
54+
}
55+
56+
function isAbortError(e: unknown): boolean {
57+
if (e instanceof AbortError) return true;
58+
59+
if (!e || typeof e !== 'object') {
60+
return false;
61+
}
62+
63+
const err = e as { name?: unknown; code?: unknown };
64+
if (typeof err.name === 'string' && err.name === 'AbortError') return true;
65+
if (typeof err.code === 'string' && err.code === 'ABORT_ERR') return true;
66+
67+
return false;
68+
}
69+
2670
export async function claudeRemoteLauncher(session: Session): Promise<'switch' | 'exit'> {
2771
logger.debug('[claudeRemoteLauncher] Starting remote launcher');
2872

@@ -327,6 +371,7 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' |
327371
const remoteResult = await claudeRemote({
328372
sessionId: session.sessionId,
329373
path: session.path,
374+
sessionPath: session.sessionInfo?.path,
330375
allowedTools: session.allowedTools ?? [],
331376
mcpServers: session.mcpServers,
332377
hookSettingsPath: session.hookSettingsPath,
@@ -363,10 +408,10 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' |
363408
// Exit
364409
return null;
365410
},
366-
onSessionFound: (sessionId) => {
411+
onSessionFound: (sessionId, sessionPath) => {
367412
// Update converter's session ID when new session is found
368413
sdkToLogConverter.updateSessionId(sessionId);
369-
session.onSessionFound(sessionId);
414+
session.onSessionFound(sessionId, sessionPath);
370415
},
371416
onThinkingChange: session.onThinkingChange,
372417
claudeEnvVars: session.claudeEnvVars,
@@ -400,8 +445,20 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' |
400445
session.client.sendSessionEvent({ type: 'message', message: 'Aborted by user' });
401446
}
402447
} catch (e) {
403-
logger.debug('[remote]: launch error', e);
448+
const abortError = isAbortError(e);
449+
logger.debug('[remote]: launch error', {
450+
...getLaunchErrorInfo(e),
451+
abortError,
452+
});
453+
404454
if (!exitReason) {
455+
if (abortError) {
456+
if (controller.signal.aborted) {
457+
session.client.sendSessionEvent({ type: 'message', message: 'Aborted by user' });
458+
}
459+
continue;
460+
}
461+
405462
session.client.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' });
406463
continue;
407464
}
@@ -457,4 +514,4 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' |
457514
}
458515

459516
return exitReason || 'exit';
460-
}
517+
}

src/claude/session.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import { MessageQueue2 } from "@/utils/MessageQueue2";
33
import { EnhancedMode } from "./loop";
44
import { logger } from "@/ui/logger";
55

6+
interface SessionInfo {
7+
id: string;
8+
path: string;
9+
}
10+
611
export class Session {
712
readonly path: string;
813
readonly logPath: string;
@@ -17,7 +22,7 @@ export class Session {
1722
/** Path to temporary settings file with SessionStart hook (required for session tracking) */
1823
readonly hookSettingsPath: string;
1924

20-
sessionId: string | null;
25+
sessionInfo: SessionInfo | null;
2126
mode: 'local' | 'remote' = 'local';
2227
thinking: boolean = false;
2328

@@ -33,6 +38,7 @@ export class Session {
3338
path: string,
3439
logPath: string,
3540
sessionId: string | null,
41+
sessionPath?: string,
3642
claudeEnvVars?: Record<string, string>,
3743
claudeArgs?: string[],
3844
mcpServers: Record<string, any>,
@@ -46,7 +52,10 @@ export class Session {
4652
this.api = opts.api;
4753
this.client = opts.client;
4854
this.logPath = opts.logPath;
49-
this.sessionId = opts.sessionId;
55+
this.sessionInfo = opts.sessionId ? {
56+
id: opts.sessionId,
57+
path: opts.sessionPath ?? opts.path
58+
} : null;
5059
this.queue = opts.messageQueue;
5160
this.claudeEnvVars = opts.claudeEnvVars;
5261
this.claudeArgs = opts.claudeArgs;
@@ -61,6 +70,10 @@ export class Session {
6170
this.client.keepAlive(this.thinking, this.mode);
6271
}, 2000);
6372
}
73+
74+
get sessionId(): string | null {
75+
return this.sessionInfo?.id ?? null;
76+
}
6477

6578
/**
6679
* Cleanup resources (call when session is no longer needed)
@@ -93,8 +106,11 @@ export class Session {
93106
* Updates internal state, syncs to API metadata, and notifies
94107
* all registered callbacks (e.g., SessionScanner) about the change.
95108
*/
96-
onSessionFound = (sessionId: string) => {
97-
this.sessionId = sessionId;
109+
onSessionFound = (sessionId: string, sessionPath?: string) => {
110+
this.sessionInfo = {
111+
id: sessionId,
112+
path: sessionPath ?? this.path
113+
};
98114

99115
// Update metadata with Claude Code session ID
100116
this.client.updateMetadata((metadata) => ({
@@ -130,7 +146,7 @@ export class Session {
130146
* Clear the current session ID (used by /clear command)
131147
*/
132148
clearSessionId = (): void => {
133-
this.sessionId = null;
149+
this.sessionInfo = null;
134150
logger.debug('[Session] Session ID cleared');
135151
}
136152

@@ -176,4 +192,4 @@ export class Session {
176192
this.claudeArgs = filteredArgs.length > 0 ? filteredArgs : undefined;
177193
logger.debug(`[Session] Consumed one-time flags, remaining args:`, this.claudeArgs);
178194
}
179-
}
195+
}

src/claude/utils/claudeCheckSession.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { existsSync, readFileSync } from "node:fs";
33
import { join } from "node:path";
44
import { getProjectPath } from "./path";
55

6-
export function claudeCheckSession(sessionId: string, path: string) {
7-
const projectDir = getProjectPath(path);
6+
export function claudeCheckSession(sessionId: string, path: string, sessionPath?: string) {
7+
const projectDir = getProjectPath(sessionPath ?? path);
88

99
// Check if session id is in the project dir
1010
const sessionFile = join(projectDir, `${sessionId}.jsonl`);
@@ -25,4 +25,4 @@ export function claudeCheckSession(sessionId: string, path: string) {
2525
});
2626

2727
return hasGoodMessage;
28-
}
28+
}

src/ui/logger.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,18 @@ class Logger {
200200
}
201201

202202
private logToFile(prefix: string, message: string, ...args: unknown[]): void {
203-
const logLine = `${prefix} ${message} ${args.map(arg =>
204-
typeof arg === 'string' ? arg : JSON.stringify(arg)
205-
).join(' ')}\n`
203+
const logLine = `${prefix} ${message} ${args.map(arg => {
204+
if (typeof arg === 'string') return arg;
205+
if (arg instanceof Error) {
206+
return JSON.stringify({
207+
name: arg.name,
208+
message: arg.message,
209+
stack: arg.stack,
210+
...(arg as unknown as Record<string, unknown>)
211+
});
212+
}
213+
return JSON.stringify(arg);
214+
}).join(' ')}\n`
206215

207216
// Send to remote server if configured
208217
if (this.dangerouslyUnencryptedServerLoggingUrl) {

0 commit comments

Comments
 (0)