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
2 changes: 1 addition & 1 deletion cli/src/api/apiMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export class ApiMachineClient {
case 'requestToApproveDirectoryCreation':
return { type: 'requestToApproveDirectoryCreation', directory: result.directory }
case 'error':
return { type: 'error', errorMessage: result.errorMessage }
throw new Error(result.errorMessage)
}
})

Expand Down
9 changes: 1 addition & 8 deletions cli/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,7 @@ export const RunnerStateSchema = z.object({
httpPort: z.number().optional(),
startedAt: z.number().optional(),
shutdownRequestedAt: z.number().optional(),
shutdownSource: z.union([z.enum(['mobile-app', 'cli', 'os-signal', 'unknown']), z.string()]).optional(),
lastSpawnError: z.object({
message: z.string(),
pid: z.number().optional(),
exitCode: z.number().nullable().optional(),
signal: z.string().nullable().optional(),
at: z.number()
}).nullable().optional()
shutdownSource: z.union([z.enum(['mobile-app', 'cli', 'os-signal', 'unknown']), z.string()]).optional()
})

export type RunnerState = z.infer<typeof RunnerStateSchema>
Expand Down
6 changes: 6 additions & 0 deletions cli/src/claude/claudeLocal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ export async function claudeLocal(opts: {
args.push(...opts.claudeArgs);
}

// Bypass Claude's built-in terminal permission prompts.
// In HAPI, permissions are managed by the web UI via hooks/settings,
// not by Claude's interactive terminal prompts. Without this, subagent
// and teammate tool calls block waiting for terminal input that never comes.
args.push('--dangerously-skip-permissions');

// Add hook settings for session tracking
args.push('--settings', opts.hookSettingsPath);
logger.debug(`[ClaudeLocal] Using hook settings: ${opts.hookSettingsPath}`);
Expand Down
5 changes: 4 additions & 1 deletion cli/src/claude/claudeRemote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,10 @@ export async function claudeRemote(opts: {
cwd: opts.path,
resume: startFrom ?? undefined,
mcpServers: opts.mcpServers,
permissionMode: initial.mode.permissionMode,
// Use 'bypassPermissions' so the SDK auto-approves teammate/sub-agent
// permissions internally. Main agent permissions are still gated by
// canCallTool (which ignores this mode and uses its own approval flow).
permissionMode: 'bypassPermissions',
model: initial.mode.model,
fallbackModel: initial.mode.fallbackModel,
customSystemPrompt: initial.mode.customSystemPrompt ? initial.mode.customSystemPrompt + '\n\n' + systemPrompt : undefined,
Expand Down
2 changes: 1 addition & 1 deletion cli/src/claude/sdk/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ export class Query implements AsyncIterableIterator<SDKMessage> {
signal
})
}

throw new Error('Unsupported control request subtype: ' + request.request.subtype)
}

Expand Down
7 changes: 6 additions & 1 deletion cli/src/claude/utils/permissionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,12 @@ export class PermissionHandler extends BasePermissionHandler<PermissionResponse,
await delay(1000);
toolCallId = this.resolveToolCallId(toolName, input);
if (!toolCallId) {
throw new Error(`Could not resolve tool call ID for ${toolName}`);
// Sub-agent / teammate tool calls don't appear in parent's toolCalls list.
// Auto-approve: the parent already authorized running the sub-agent,
// and in remote mode there's no reliable path to relay permission
// responses back to the sub-agent's internal permission handler.
logger.debug(`Auto-approving sub-agent tool: ${toolName}`);
return { behavior: 'allow', updatedInput: input as Record<string, unknown> };
}
}
return this.handlePermissionRequest(toolCallId, toolName, input, options.signal);
Expand Down
22 changes: 22 additions & 0 deletions cli/src/modules/common/hooks/generateHookSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ type HookSettings = {
hooks: {
SessionStart: HookCommandConfig[];
};
permissions?: {
deny?: string[];
};
};

export type HookSettingsOptions = {
Expand Down Expand Up @@ -65,6 +68,25 @@ function buildHookSettings(command: string, hooksEnabled?: boolean): HookSetting
};
}

// Deny dangerous Bash patterns as a safety net.
// Even with --dangerously-skip-permissions, deny rules are still enforced.
settings.permissions = {
deny: [
'Bash(rm -rf:*)',
'Bash(rm -r /:*)',
'Bash(sudo rm:*)',
'Bash(sudo chmod:*)',
'Bash(sudo chown:*)',
'Bash(mkfs:*)',
'Bash(dd if=:*)',
'Bash(git push --force:*)',
'Bash(git push -f:*)',
'Bash(git reset --hard:*)',
'Bash(> /dev/:*)',
'Bash(chmod 777:*)',
]
};

return settings;
}

Expand Down
152 changes: 4 additions & 148 deletions cli/src/runner/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,20 +127,6 @@ export async function startRunner(): Promise<void> {

// Session spawning awaiter system
const pidToAwaiter = new Map<number, (session: TrackedSession) => void>();
const pidToErrorAwaiter = new Map<number, (errorMessage: string) => void>();
type SpawnFailureDetails = {
message: string
pid?: number
exitCode?: number | null
signal?: NodeJS.Signals | null
};
let reportSpawnOutcomeToHub: ((outcome: { type: 'success' } | { type: 'error'; details: SpawnFailureDetails }) => void) | null = null;
const formatSpawnError = (error: unknown): string => {
if (error instanceof Error) {
return error.message;
}
return String(error);
};

// Helper functions
const getCurrentChildren = () => Array.from(pidToTrackedSession.values());
Expand Down Expand Up @@ -171,7 +157,6 @@ export async function startRunner(): Promise<void> {
const awaiter = pidToAwaiter.get(pid);
if (awaiter) {
pidToAwaiter.delete(pid);
pidToErrorAwaiter.delete(pid);
awaiter(existingSession);
logger.debug(`[RUNNER RUN] Resolved session awaiter for PID ${pid}`);
}
Expand Down Expand Up @@ -398,66 +383,17 @@ export async function startRunner(): Promise<void> {
stderrTail = appendTail(stderrTail, data);
});

let spawnErrorBeforePidCheck: Error | null = null;
const captureSpawnErrorBeforePidCheck = (error: Error) => {
spawnErrorBeforePidCheck = error;
};
happyProcess.once('error', captureSpawnErrorBeforePidCheck);

if (!happyProcess.pid) {
// Allow the async 'error' event to fire before we read it
await new Promise((resolve) => setImmediate(resolve));
const details = [`cwd=${spawnDirectory}`];
if (spawnErrorBeforePidCheck) {
details.push(formatSpawnError(spawnErrorBeforePidCheck));
}
const errorMessage = `Failed to spawn HAPI process - no PID returned (${details.join('; ')})`;
logger.debug('[RUNNER RUN] Failed to spawn process - no PID returned', spawnErrorBeforePidCheck ?? null);
reportSpawnOutcomeToHub?.({
type: 'error',
details: {
message: errorMessage
}
});
logger.debug('[RUNNER RUN] Failed to spawn process - no PID returned');
await maybeCleanupWorktree('no-pid');
return {
type: 'error',
errorMessage
errorMessage: 'Failed to spawn HAPI process - no PID returned'
};
}
happyProcess.removeListener('error', captureSpawnErrorBeforePidCheck);

const pid = happyProcess.pid;
logger.debug(`[RUNNER RUN] Spawned process with PID ${pid}`);
let observedExitCode: number | null = null;
let observedExitSignal: NodeJS.Signals | null = null;
const buildWebhookFailureMessage = (reason: 'timeout' | 'exit-before-webhook' | 'process-error-before-webhook'): string => {
let message = '';
if (reason === 'exit-before-webhook') {
message = `Session process exited before webhook for PID ${pid}`;
} else if (reason === 'process-error-before-webhook') {
message = `Session process error before webhook for PID ${pid}`;
} else {
message = `Session webhook timeout for PID ${pid}`;
}

if (observedExitCode !== null || observedExitSignal) {
if (observedExitCode !== null) {
message += ` (exit code ${observedExitCode})`;
} else {
message += ` (signal ${observedExitSignal})`;
}
}

const trimmedTail = stderrTail.trim();
if (trimmedTail) {
const compactTail = trimmedTail.replace(/\s+/g, ' ');
const tailForMessage = compactTail.length > 800 ? compactTail.slice(-800) : compactTail;
message += `. stderr: ${tailForMessage}`;
}

return message;
};

const trackedSession: TrackedSession = {
startedBy: 'runner',
Expand All @@ -470,29 +406,15 @@ export async function startRunner(): Promise<void> {
pidToTrackedSession.set(pid, trackedSession);

happyProcess.on('exit', (code, signal) => {
observedExitCode = typeof code === 'number' ? code : null;
observedExitSignal = signal ?? null;
logger.debug(`[RUNNER RUN] Child PID ${pid} exited with code ${code}, signal ${signal}`);
if (code !== 0 || signal) {
logStderrTail();
}
const errorAwaiter = pidToErrorAwaiter.get(pid);
if (errorAwaiter) {
pidToErrorAwaiter.delete(pid);
pidToAwaiter.delete(pid);
errorAwaiter(buildWebhookFailureMessage('exit-before-webhook'));
}
onChildExited(pid);
});

happyProcess.on('error', (error) => {
logger.debug(`[RUNNER RUN] Child process error:`, error);
const errorAwaiter = pidToErrorAwaiter.get(pid);
if (errorAwaiter) {
pidToErrorAwaiter.delete(pid);
pidToAwaiter.delete(pid);
errorAwaiter(buildWebhookFailureMessage('process-error-before-webhook'));
}
onChildExited(pid);
});

Expand All @@ -503,12 +425,11 @@ export async function startRunner(): Promise<void> {
// Set timeout for webhook
const timeout = setTimeout(() => {
pidToAwaiter.delete(pid);
pidToErrorAwaiter.delete(pid);
logger.debug(`[RUNNER RUN] Session webhook timeout for PID ${pid}`);
logStderrTail();
resolve({
type: 'error',
errorMessage: buildWebhookFailureMessage('timeout')
errorMessage: `Session webhook timeout for PID ${pid}`
});
// 15 second timeout - I have seen timeouts on 10 seconds
// even though session was still created successfully in ~2 more seconds
Expand All @@ -517,46 +438,21 @@ export async function startRunner(): Promise<void> {
// Register awaiter
pidToAwaiter.set(pid, (completedSession) => {
clearTimeout(timeout);
pidToErrorAwaiter.delete(pid);
logger.debug(`[RUNNER RUN] Session ${completedSession.happySessionId} fully spawned with webhook`);
resolve({
type: 'success',
sessionId: completedSession.happySessionId!
});
});
pidToErrorAwaiter.set(pid, (errorMessage) => {
clearTimeout(timeout);
resolve({
type: 'error',
errorMessage
});
});
});
if (spawnResult.type === 'error') {
reportSpawnOutcomeToHub?.({
type: 'error',
details: {
message: spawnResult.errorMessage,
pid,
exitCode: observedExitCode,
signal: observedExitSignal
}
});
if (spawnResult.type !== 'success') {
await maybeCleanupWorktree('spawn-error');
} else {
reportSpawnOutcomeToHub?.({ type: 'success' });
}
return spawnResult;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.debug('[RUNNER RUN] Failed to spawn session:', error);
await maybeCleanupWorktree('exception');
reportSpawnOutcomeToHub?.({
type: 'error',
details: {
message: `Failed to spawn session: ${errorMessage}`
}
});
return {
type: 'error',
errorMessage: `Failed to spawn session: ${errorMessage}`
Expand Down Expand Up @@ -604,8 +500,6 @@ export async function startRunner(): Promise<void> {
const onChildExited = (pid: number) => {
logger.debug(`[RUNNER RUN] Removing exited process PID ${pid} from tracking`);
pidToTrackedSession.delete(pid);
pidToAwaiter.delete(pid);
pidToErrorAwaiter.delete(pid);
};

// Start control server
Expand Down Expand Up @@ -675,44 +569,6 @@ export async function startRunner(): Promise<void> {
// Connect to server
apiMachine.connect();

reportSpawnOutcomeToHub = (outcome) => {
void apiMachine.updateRunnerState((state: RunnerState | null) => {
const baseState: RunnerState = state
? { ...state }
: { status: 'running' };

if (typeof baseState.pid !== 'number') {
baseState.pid = process.pid;
}
if (typeof baseState.httpPort !== 'number') {
baseState.httpPort = controlPort;
}
if (typeof baseState.startedAt !== 'number') {
baseState.startedAt = Date.now();
}

if (outcome.type === 'success') {
return {
...baseState,
lastSpawnError: null
};
}

return {
...baseState,
lastSpawnError: {
message: outcome.details.message,
pid: outcome.details.pid,
exitCode: outcome.details.exitCode ?? null,
signal: outcome.details.signal ?? null,
at: Date.now()
}
};
}).catch((error) => {
logger.debug('[RUNNER RUN] Failed to update runner state with spawn outcome', error);
});
};

// Every 60 seconds:
// 1. Prune stale sessions
// 2. Check if runner needs update
Expand Down
19 changes: 16 additions & 3 deletions hub/src/socket/handlers/cli/sessionHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ClientToServerEvents } from '@hapi/protocol'
import { z } from 'zod'
import { randomUUID } from 'node:crypto'
import type { CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types'
import type { CodexCollaborationMode, PermissionMode, TeamState } from '@hapi/protocol/types'
import type { Store, StoredSession } from '../../../store'
import type { SyncEvent } from '../../../sync/syncEngine'
import { extractTodoWriteTodosFromMessageContent } from '../../../sync/todos'
Expand Down Expand Up @@ -99,13 +99,23 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session

const teamDelta = extractTeamStateFromMessageContent(content)
if (teamDelta) {
const existingSession = store.sessions.getSession(sid)
const existingSession = store.sessions.getSessionByNamespace(sid, session.namespace)
const existingTeamState = existingSession?.teamState as import('@hapi/protocol/types').TeamState | null | undefined
const newTeamState = applyTeamStateDelta(existingTeamState ?? null, teamDelta)
const updated = store.sessions.setSessionTeamState(sid, newTeamState, msg.createdAt, session.namespace)
// Guard against accidental team-state wipe:
// if we only got an incremental update but no existing team state, skip persistence.
const shouldPersist = !(teamDelta._action === 'update' && !existingTeamState && newTeamState === null)
const updated = shouldPersist
? store.sessions.setSessionTeamState(sid, newTeamState, msg.createdAt, session.namespace)
: false
if (updated) {
onWebappEvent?.({ type: 'session-updated', sessionId: sid, data: { sid } })
}

// Note: teammate permission_request messages are part of Claude's internal
// team protocol. They cannot be approved via RPC — the teammate resolves
// permissions through its own SendMessage-based approval flow with the
// team lead agent. We no longer attempt auto-approve here.
}

const update = {
Expand Down Expand Up @@ -228,6 +238,9 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session
}
socket.to(`session:${sid}`).emit('update', update)
onWebappEvent?.({ type: 'session-updated', sessionId: sid, data: { sid } })

// Note: teammate permissions are resolved internally by the team lead
// agent via SendMessage. We no longer sync or auto-approve them.
}
}

Expand Down
Loading
Loading