From 3291fab5da1dac704d267b7a409390414c70e9ae Mon Sep 17 00:00:00 2001 From: Federico Liva Date: Fri, 28 Aug 2026 18:09:46 +0200 Subject: [PATCH 1/2] fix(cli): stop forwarding a literal 'default' permission mode to Claude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mapToClaudeMode` passed the literal 'default' through unchanged, so the Claude child was spawned with an explicit `--permission-mode default`. A CLI flag outranks the settings file, so a user whose `permissions.defaultMode` is `auto` silently lost it and was prompted on nearly every tool call — a worse outcome than sending no mode at all. Claude's `default` is the ambient "no override" value, the same thing the app expresses by omitting the field, so it now maps to undefined and the SDK applies the user's own configuration. Codex's `safe-yolo` and `read-only` still map to a literal 'default': for them asking is the policy that was picked, not the absence of one. Normalizing at the SDK boundary rather than in the app also makes the CLI robust against client builds that predate 4cdb8b10 and still send the literal spelling on every spawn and resume. `handleModeChange` now guards on the mapped value, so switching to Default no longer tries to push undefined into a live query — matching the behavior its own comment already described. Refs #1695 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .../src/claude/utils/permissionHandler.ts | 7 +++--- .../src/claude/utils/permissionMode.test.ts | 24 +++++++++++++++---- .../src/claude/utils/permissionMode.ts | 15 ++++++++---- 3 files changed, 33 insertions(+), 13 deletions(-) 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 = { From d0a8b05464314d69af4c7d4aaa48e561aae3a774 Mon Sep 17 00:00:00 2001 From: Federico Liva Date: Mon, 31 Aug 2026 09:53:23 +0200 Subject: [PATCH 2/2] fix(cli): keep terminal-started sessions resumable after they exit The daemon looks up a session for `resume-happy-session` in two places: `pidToTrackedSession` for live sessions, and `sessionIdToFinishedSession` for ones that have already exited. Only `onChildExited` ever populates the second map, and it fires exclusively for processes the daemon spawned itself. Sessions started from a terminal register through the local webhook, so they are tracked but are not daemon children. When such a process dies, the only thing that notices is the heartbeat's stale-PID sweep, which dropped the entry from `pidToTrackedSession` without preserving it. From that moment `resume-happy-session` failed with "Session is not tracked by this daemon", and stayed broken until a daemon restart repopulated `sessionIdToFinishedSession` from `~/.happy/sessions.json`. Route the stale-PID sweep through `onChildExited` so both exit paths preserve the session the same way. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- packages/happy-cli/src/daemon/run.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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); } }