Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions packages/happy-cli/src/claude/utils/permissionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
Expand Down
24 changes: 19 additions & 5 deletions packages/happy-cli/src/claude/utils/permissionMode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand All @@ -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);
Expand All @@ -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', () => {
Expand Down
15 changes: 10 additions & 5 deletions packages/happy-cli/src/claude/utils/permissionMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,23 @@ export type ClaudeSdkPermissionMode = NonNullable<QueryOptions['permissionMode']
* - read-only → default (Claude doesn't support read-only)
*
* Claude modes pass through unchanged:
* - auto, default, acceptEdits, bypassPermissions, plan
* - auto, acceptEdits, bypassPermissions, plan
*
* `auto` is a first-class mode in the Agent SDK's own PermissionMode union,
* so it passes straight through rather than being mapped onto `default`.
*
* Claude's `default` is the one exception: it is the ambient "no override"
* value, so it maps to undefined and the SDK is left to apply the user's own
* `permissions.defaultMode`. Forwarding the literal would spawn Claude with an
* explicit `--permission-mode default`, and a flag outranks the settings file —
* a user whose defaultMode is `auto` would be dropped back to prompting. Codex's
* safe-yolo and read-only still map to a literal 'default' because for them
* asking is the policy that was picked, not the absence of one.
*/
export function mapToClaudeMode(mode: undefined): undefined;
export function mapToClaudeMode(mode: PermissionMode): ClaudeSdkPermissionMode;
export function mapToClaudeMode(mode: PermissionMode | undefined): ClaudeSdkPermissionMode | undefined;
export function mapToClaudeMode(mode: PermissionMode | undefined): ClaudeSdkPermissionMode | undefined {
// Undefined is a meaningful value, not a missing one: it is how "Default"
// reaches the SDK, which then applies Claude's own configuration.
if (mode === undefined) {
if (mode === undefined || mode === 'default') {
return undefined;
}
const codexToClaudeMap: Record<string, ClaudeSdkPermissionMode> = {
Expand Down
10 changes: 7 additions & 3 deletions packages/happy-cli/src/daemon/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -912,9 +912,13 @@ export async function startDaemon(): Promise<void> {
// 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);
}
}

Expand Down