feat(cli): inherit local TUI permission mode on local→remote switch

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).
This commit is contained in:
weishu
2026-08-01 23:40:46 +08:00
parent 9727f7e4a0
commit 3e61d276bd
9 changed files with 138 additions and 15 deletions
+1 -1
View File
@@ -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: () => {
+2
View File
@@ -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
+37 -2
View File
@@ -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<void> {
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<void> {
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<void> {
happyServer.stop();
hookServer.stop();
cleanupHookSettingsFile(hookSettingsPath, 'generateHookSettings');
cleanupHookSettingsFile(localHookSettingsPath, 'generateHookSettings');
}
});
@@ -437,7 +471,8 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
claudeArgs: options.claudeArgs,
startedBy,
resumeSessionId: options.resumeSessionId,
hookSettingsPath
hookSettingsPath,
localHookSettingsPath
});
} catch (error) {
loopError = error;
+4
View File
@@ -18,6 +18,8 @@ export class Session extends AgentSessionBase<EnhancedMode> {
readonly mcpServers: Record<string, any>;
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<EnhancedMode> {
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<EnhancedMode> {
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;
@@ -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()
})
})
@@ -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;
}
+2
View File
@@ -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;
}
@@ -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')
})
})
@@ -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}`);