From 3e61d276bdacd50bceb123302f026f28cf79686c Mon Sep 17 00:00:00 2001 From: weishu Date: Sat, 1 Aug 2026 23:40:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(cli):=20inherit=20local=20TUI=20permission?= =?UTF-8?q?=20mode=20on=20local=E2=86=92remote=20switch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add permission mode tracking across the local→remote switch so mode changes made inside the interactive Claude TUI (shift+tab) are inherited when launching remote. Previously, a mode picked in local TUI was invisible to remote sessions. Implementation: - generateHookSettings: add trackPermissionMode option to register UserPromptSubmit and PreToolUse hooks (their payloads carry permission_mode; SessionStart's does not). Export buildHookSettings for testing and make matcher optional. - New hookPermissionMode.ts: normalizer for hook permission_mode → HAPI mode ('manual' → 'default'; unknown modes like 'dontAsk' → null/ignored). - runClaude.ts: generate a second, local-TUI-only hook settings file with trackPermissionMode enabled (remote SDK process keeps the SessionStart-only file — these hooks block Claude per prompt/tool, and remote state is owned by hub/RPC). Hook callback inherits mode when session.mode === 'local': updates currentPermissionMode, syncs session, pushes keepalive, emits permission-mode- changed event. - session.ts, loop.ts, claudeLocalLauncher.ts: plumb localHookSettingsPath (defaults to hookSettingsPath when not provided). --- cli/src/claude/claudeLocalLauncher.ts | 2 +- cli/src/claude/loop.ts | 2 + cli/src/claude/runClaude.ts | 39 ++++++++++++++++++- cli/src/claude/session.ts | 4 ++ .../claude/utils/hookPermissionMode.test.ts | 28 +++++++++++++ cli/src/claude/utils/hookPermissionMode.ts | 23 +++++++++++ cli/src/claude/utils/startHookServer.ts | 2 + .../common/hooks/generateHookSettings.test.ts | 17 ++++++++ .../common/hooks/generateHookSettings.ts | 36 +++++++++++------ 9 files changed, 138 insertions(+), 15 deletions(-) create mode 100644 cli/src/claude/utils/hookPermissionMode.test.ts create mode 100644 cli/src/claude/utils/hookPermissionMode.ts create mode 100644 cli/src/modules/common/hooks/generateHookSettings.test.ts diff --git a/cli/src/claude/claudeLocalLauncher.ts b/cli/src/claude/claudeLocalLauncher.ts index 736540be..db4bf6c1 100644 --- a/cli/src/claude/claudeLocalLauncher.ts +++ b/cli/src/claude/claudeLocalLauncher.ts @@ -61,7 +61,7 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' | model: session.getModel(), mcpServers: session.mcpServers, allowedTools: session.allowedTools, - hookSettingsPath: session.hookSettingsPath, + hookSettingsPath: session.localHookSettingsPath, }); }, onLaunchSuccess: () => { diff --git a/cli/src/claude/loop.ts b/cli/src/claude/loop.ts index a7846171..298897d0 100644 --- a/cli/src/claude/loop.ts +++ b/cli/src/claude/loop.ts @@ -39,6 +39,7 @@ interface LoopOptions { allowedTools?: string[] onSessionReady?: (session: Session) => void hookSettingsPath: string + localHookSettingsPath?: string resumeSessionId?: string } @@ -64,6 +65,7 @@ export async function loop(opts: LoopOptions) { startedBy, startingMode, hookSettingsPath: opts.hookSettingsPath, + localHookSettingsPath: opts.localHookSettingsPath, permissionMode: opts.permissionMode ?? 'default', model: opts.model, effort: opts.effort diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index 09f349d9..f9476707 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -21,6 +21,7 @@ import { PermissionModeSchema } from '@hapi/protocol/schemas'; import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; import { normalizeClaudeSessionModel } from './model'; import { normalizeClaudeSessionEffort } from './effort'; +import { normalizeHookPermissionMode } from './utils/hookPermissionMode'; import { getInvokedCwd } from '@/utils/invokedCwd'; export interface StartOptions { @@ -118,6 +119,27 @@ export async function runClaude(options: StartOptions = {}): Promise { currentSession.onSessionFound(sessionId); } } + + // Inherit the mode the user picked inside the interactive TUI + // (shift+tab) so a later local→remote switch keeps it. Only the + // local settings file registers the hooks that carry + // permission_mode, but gate on local mode anyway so a remote SDK + // process can never fight with web/RPC-driven state. + const hookPermissionMode = normalizeHookPermissionMode(data.permission_mode); + if ( + hookPermissionMode + && currentSession?.mode === 'local' + && hookPermissionMode !== currentPermissionMode + ) { + logger.debug(`[START] Inheriting permission mode from local Claude: ${currentPermissionMode} -> ${hookPermissionMode}`); + currentPermissionMode = hookPermissionMode; + syncSessionModes(); + currentSession.pushKeepAlive(); + session.sendSessionEvent({ + type: 'permission-mode-changed', + mode: hookPermissionMode + }); + } } }); logger.debug(`[START] Hook server started on port ${hookServer.port}`); @@ -126,7 +148,18 @@ export async function runClaude(options: StartOptions = {}): Promise { filenamePrefix: 'session-hook', logLabel: 'generateHookSettings' }); - logger.debug(`[START] Generated hook settings file: ${hookSettingsPath}`); + // The interactive TUI gets a separate settings file that also forwards + // UserPromptSubmit/PreToolUse (their payloads carry permission_mode), so a + // mode picked via shift+tab is inherited on local→remote switch. The + // remote SDK process keeps the SessionStart-only file: these extra hooks + // block Claude per prompt/tool call, and remote mode state is owned by the + // hub/RPC path. + const localHookSettingsPath = generateHookSettingsFile(hookServer.port, hookServer.token, { + filenamePrefix: 'session-hook-local', + logLabel: 'generateHookSettings', + trackPermissionMode: true + }); + logger.debug(`[START] Generated hook settings files: ${hookSettingsPath}, ${localHookSettingsPath}`); // Print log file path const logPath = logger.logFilePath; @@ -141,6 +174,7 @@ export async function runClaude(options: StartOptions = {}): Promise { happyServer.stop(); hookServer.stop(); cleanupHookSettingsFile(hookSettingsPath, 'generateHookSettings'); + cleanupHookSettingsFile(localHookSettingsPath, 'generateHookSettings'); } }); @@ -437,7 +471,8 @@ export async function runClaude(options: StartOptions = {}): Promise { claudeArgs: options.claudeArgs, startedBy, resumeSessionId: options.resumeSessionId, - hookSettingsPath + hookSettingsPath, + localHookSettingsPath }); } catch (error) { loopError = error; diff --git a/cli/src/claude/session.ts b/cli/src/claude/session.ts index 2106d312..687ec0fb 100644 --- a/cli/src/claude/session.ts +++ b/cli/src/claude/session.ts @@ -18,6 +18,8 @@ export class Session extends AgentSessionBase { readonly mcpServers: Record; readonly allowedTools?: string[]; readonly hookSettingsPath: string; + /** Settings for the interactive TUI: also forwards permission-mode-carrying hooks. */ + readonly localHookSettingsPath: string; readonly startedBy: 'runner' | 'terminal'; readonly startingMode: 'local' | 'remote'; localLaunchFailure: LocalLaunchFailure | null = null; @@ -38,6 +40,7 @@ export class Session extends AgentSessionBase { startedBy: 'runner' | 'terminal'; startingMode: 'local' | 'remote'; hookSettingsPath: string; + localHookSettingsPath?: string; permissionMode?: PermissionMode; model?: SessionModel; effort?: SessionEffort; @@ -67,6 +70,7 @@ export class Session extends AgentSessionBase { this.mcpServers = opts.mcpServers; this.allowedTools = opts.allowedTools; this.hookSettingsPath = opts.hookSettingsPath; + this.localHookSettingsPath = opts.localHookSettingsPath ?? opts.hookSettingsPath; this.startedBy = opts.startedBy; this.startingMode = opts.startingMode; this.permissionMode = opts.permissionMode; diff --git a/cli/src/claude/utils/hookPermissionMode.test.ts b/cli/src/claude/utils/hookPermissionMode.test.ts new file mode 100644 index 00000000..4d24872d --- /dev/null +++ b/cli/src/claude/utils/hookPermissionMode.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { normalizeHookPermissionMode } from './hookPermissionMode' + +describe('normalizeHookPermissionMode', () => { + it('passes through HAPI claude modes', () => { + expect(normalizeHookPermissionMode('default')).toBe('default') + expect(normalizeHookPermissionMode('acceptEdits')).toBe('acceptEdits') + expect(normalizeHookPermissionMode('auto')).toBe('auto') + expect(normalizeHookPermissionMode('bypassPermissions')).toBe('bypassPermissions') + expect(normalizeHookPermissionMode('plan')).toBe('plan') + }) + + it("maps claude's 'manual' to 'default'", () => { + expect(normalizeHookPermissionMode('manual')).toBe('default') + }) + + it('rejects modes HAPI has no claude equivalent for', () => { + expect(normalizeHookPermissionMode('dontAsk')).toBeNull() + expect(normalizeHookPermissionMode('yolo')).toBeNull() + expect(normalizeHookPermissionMode('garbage')).toBeNull() + }) + + it('rejects non-string payloads', () => { + expect(normalizeHookPermissionMode(undefined)).toBeNull() + expect(normalizeHookPermissionMode(null)).toBeNull() + expect(normalizeHookPermissionMode(42)).toBeNull() + }) +}) diff --git a/cli/src/claude/utils/hookPermissionMode.ts b/cli/src/claude/utils/hookPermissionMode.ts new file mode 100644 index 00000000..ab0bfe9c --- /dev/null +++ b/cli/src/claude/utils/hookPermissionMode.ts @@ -0,0 +1,23 @@ +import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; +import { PermissionModeSchema } from '@hapi/protocol/schemas'; +import type { PermissionMode } from '../loop'; + +/** + * Normalize the `permission_mode` field from a Claude Code hook payload + * (UserPromptSubmit / PreToolUse) into a HAPI Claude permission mode. + * + * Claude 2.1.x calls the base mode `manual` in some surfaces; HAPI calls it + * `default`. Modes HAPI has no equivalent for (e.g. `dontAsk`) return null and + * are ignored by the caller. + */ +export function normalizeHookPermissionMode(value: unknown): PermissionMode | null { + if (typeof value !== 'string') { + return null; + } + const mapped = value === 'manual' ? 'default' : value; + const parsed = PermissionModeSchema.safeParse(mapped); + if (!parsed.success || !isPermissionModeAllowedForFlavor(parsed.data, 'claude')) { + return null; + } + return parsed.data as PermissionMode; +} diff --git a/cli/src/claude/utils/startHookServer.ts b/cli/src/claude/utils/startHookServer.ts index 5e910500..8f3939c1 100644 --- a/cli/src/claude/utils/startHookServer.ts +++ b/cli/src/claude/utils/startHookServer.ts @@ -18,6 +18,8 @@ export interface SessionHookData { cwd?: string; hook_event_name?: string; source?: string; + /** Present on UserPromptSubmit/PreToolUse hooks; absent on SessionStart. */ + permission_mode?: unknown; [key: string]: unknown; } diff --git a/cli/src/modules/common/hooks/generateHookSettings.test.ts b/cli/src/modules/common/hooks/generateHookSettings.test.ts new file mode 100644 index 00000000..b61f221b --- /dev/null +++ b/cli/src/modules/common/hooks/generateHookSettings.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { buildHookSettings } from './generateHookSettings' + +describe('buildHookSettings', () => { + it('registers only SessionStart by default', () => { + const settings = buildHookSettings('forward-cmd') + expect(Object.keys(settings.hooks)).toEqual(['SessionStart']) + expect(settings.hooks.SessionStart[0].hooks[0].command).toBe('forward-cmd') + }) + + it('adds permission-mode-carrying hooks when trackPermissionMode is set', () => { + const settings = buildHookSettings('forward-cmd', undefined, true) + expect(settings.hooks.UserPromptSubmit?.[0].hooks[0].command).toBe('forward-cmd') + expect(settings.hooks.PreToolUse?.[0].matcher).toBe('*') + expect(settings.hooks.PreToolUse?.[0].hooks[0].command).toBe('forward-cmd') + }) +}) diff --git a/cli/src/modules/common/hooks/generateHookSettings.ts b/cli/src/modules/common/hooks/generateHookSettings.ts index b6128013..833cd2ce 100644 --- a/cli/src/modules/common/hooks/generateHookSettings.ts +++ b/cli/src/modules/common/hooks/generateHookSettings.ts @@ -5,7 +5,7 @@ import { logger } from '@/ui/logger'; import { getHappyCliCommand } from '@/utils/spawnHappyCLI'; type HookCommandConfig = { - matcher: string; + matcher?: string; hooks: Array<{ type: 'command'; command: string; @@ -18,6 +18,8 @@ type HookSettings = { }; hooks: { SessionStart: HookCommandConfig[]; + UserPromptSubmit?: HookCommandConfig[]; + PreToolUse?: HookCommandConfig[]; }; }; @@ -25,6 +27,14 @@ export type HookSettingsOptions = { filenamePrefix: string; logLabel: string; hooksEnabled?: boolean; + /** + * Also forward UserPromptSubmit and PreToolUse hooks. Unlike SessionStart, + * their payloads carry `permission_mode`, letting HAPI track the mode the + * user picks inside the interactive TUI (shift+tab). Keep this off for the + * remote SDK process: these hooks block Claude while the forwarder runs, + * and remote permission state is owned by the hub/RPC path anyway. + */ + trackPermissionMode?: boolean; }; function shellQuote(value: string): string { @@ -43,20 +53,22 @@ function shellJoin(parts: string[]): string { return parts.map(shellQuote).join(' '); } -function buildHookSettings(command: string, hooksEnabled?: boolean): HookSettings { - const hooks: HookSettings['hooks'] = { - SessionStart: [ +export function buildHookSettings(command: string, hooksEnabled?: boolean, trackPermissionMode?: boolean): HookSettings { + const commandHook = { + hooks: [ { - matcher: '*', - hooks: [ - { - type: 'command', - command - } - ] + type: 'command' as const, + command } ] }; + const hooks: HookSettings['hooks'] = { + SessionStart: [{ matcher: '*', ...commandHook }] + }; + if (trackPermissionMode) { + hooks.UserPromptSubmit = [commandHook]; + hooks.PreToolUse = [{ matcher: '*', ...commandHook }]; + } const settings: HookSettings = { hooks }; if (hooksEnabled !== undefined) { @@ -88,7 +100,7 @@ export function generateHookSettingsFile( ]); const hookCommand = shellJoin([command, ...args]); - const settings = buildHookSettings(hookCommand, options.hooksEnabled); + const settings = buildHookSettings(hookCommand, options.hooksEnabled, options.trackPermissionMode); writeFileSync(filepath, JSON.stringify(settings, null, 4)); logger.debug(`[${options.logLabel}] Created hook settings file: ${filepath}`);