diff --git a/packages/happy-cli/src/claude/utils/permissionHandler.ts b/packages/happy-cli/src/claude/utils/permissionHandler.ts index 7ed3db8f26..5d1a088c37 100644 --- a/packages/happy-cli/src/claude/utils/permissionHandler.ts +++ b/packages/happy-cli/src/claude/utils/permissionHandler.ts @@ -72,10 +72,11 @@ export class PermissionHandler { // Only a concrete mode is pushed: setPermissionMode has no way to say // "go back to inheriting", so switching to Default leaves the running // query where it is and takes effect on the next one. - if (mode !== undefined + const mappedMode = mapToClaudeMode(mode); + if (mappedMode !== undefined && this.setPermissionModeCallback - && mapToClaudeMode(previousMode) !== mapToClaudeMode(mode)) { - this.setPermissionModeCallback(mapToClaudeMode(mode)).catch((err) => { + && mapToClaudeMode(previousMode) !== mappedMode) { + this.setPermissionModeCallback(mappedMode).catch((err) => { logger.debug('Failed to sync permission mode via SDK:', err); }); } diff --git a/packages/happy-cli/src/claude/utils/permissionMode.test.ts b/packages/happy-cli/src/claude/utils/permissionMode.test.ts index ee7dfbeb4c..a1bf488421 100644 --- a/packages/happy-cli/src/claude/utils/permissionMode.test.ts +++ b/packages/happy-cli/src/claude/utils/permissionMode.test.ts @@ -18,10 +18,6 @@ describe('mapToClaudeMode', () => { }); describe('Claude modes pass through unchanged', () => { - it('passes through default', () => { - expect(mapToClaudeMode('default')).toBe('default'); - }); - it('passes through acceptEdits', () => { expect(mapToClaudeMode('acceptEdits')).toBe('acceptEdits'); }); @@ -42,7 +38,7 @@ describe('mapToClaudeMode', () => { ]; it('returns a valid Claude mode for every PermissionMode', () => { - const validClaudeModes = ['auto', 'default', 'acceptEdits', 'bypassPermissions', 'plan']; + const validClaudeModes = ['auto', 'default', 'acceptEdits', 'bypassPermissions', 'plan', undefined]; allModes.forEach(mode => { const result = mapToClaudeMode(mode); @@ -63,6 +59,24 @@ describe('mapToClaudeMode', () => { it('keeps an unset mode unset rather than inventing one', () => { expect(mapToClaudeMode(undefined)).toBeUndefined(); }); + + // Regression (#1695): app builds predating the client-side fix send the + // literal 'default' with every spawn. Forwarding it produces an explicit + // `--permission-mode default` on the Claude command line, and a flag + // outranks settings.json — a user whose permissions.defaultMode is 'auto' + // silently loses it and is prompted on nearly every tool call. Both wire + // spellings of "no override" have to reach the SDK as undefined. + it('treats a literal Claude default as no override', () => { + expect(mapToClaudeMode('default')).toBeUndefined(); + }); + + // Codex's own ask-first policies still need a concrete mode: for them + // "ask" is the pick, not the absence of one, so they must not fall back + // to whatever the user configured for Claude. + it('still maps the Codex ask-first modes to a literal default', () => { + expect(mapToClaudeMode('safe-yolo')).toBe('default'); + expect(mapToClaudeMode('read-only')).toBe('default'); + }); }); describe('resolveInitialClaudePermissionMode with no override', () => { diff --git a/packages/happy-cli/src/claude/utils/permissionMode.ts b/packages/happy-cli/src/claude/utils/permissionMode.ts index a40af2736a..aee6736fd9 100644 --- a/packages/happy-cli/src/claude/utils/permissionMode.ts +++ b/packages/happy-cli/src/claude/utils/permissionMode.ts @@ -15,18 +15,23 @@ export type ClaudeSdkPermissionMode = NonNullable = { diff --git a/packages/happy-cli/src/daemon/run.ts b/packages/happy-cli/src/daemon/run.ts index 92f167bd73..e79e1dae15 100644 --- a/packages/happy-cli/src/daemon/run.ts +++ b/packages/happy-cli/src/daemon/run.ts @@ -912,9 +912,13 @@ export async function startDaemon(): Promise { // Check if process is still alive (signal 0 doesn't kill, just checks) process.kill(pid, 0); } catch (error) { - // Process is dead, remove from tracking - logger.debug(`[DAEMON RUN] Removing stale session with PID ${pid} (process no longer exists)`); - pidToTrackedSession.delete(pid); + // Process is dead. Go through the same path as a child exit so the + // session is preserved in `sessionIdToFinishedSession` and stays + // resumable — sessions started from a terminal are not our children, + // so `onChildExited` never fires for them and dropping them here + // silently made them unresumable until the next daemon restart. + logger.debug(`[DAEMON RUN] Reaping stale session with PID ${pid} (process no longer exists)`); + onChildExited(pid); } }