mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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: () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user