feat: create new session with yolo mode (#13)

* feat: create new session with yolo mode

Signed-off-by: Ruihang Xia <waynestxia@gmail.com>

* fix: harden sequence typecheck

* feat: add unified --yolo flag across all agents

---------

Signed-off-by: Ruihang Xia <waynestxia@gmail.com>
Co-authored-by: weishu <twsxtd@gmail.com>
This commit is contained in:
Ruihang Xia
2025-12-27 21:03:34 +08:00
committed by GitHub
co-authored by weishu
parent 126c5e483c
commit 58e61b3db9
12 changed files with 115 additions and 19 deletions
+10 -2
View File
@@ -70,13 +70,21 @@ export class ApiMachineClient {
setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void {
this.rpcHandlerManager.registerHandler('spawn-happy-session', async (params: any) => {
const { directory, sessionId, machineId, approvedNewDirectoryCreation, agent, token } = params || {}
const { directory, sessionId, machineId, approvedNewDirectoryCreation, agent, yolo, token } = params || {}
if (!directory) {
throw new Error('Directory is required')
}
const result = await spawnSession({ directory, sessionId, machineId, approvedNewDirectoryCreation, agent, token })
const result = await spawnSession({
directory,
sessionId,
machineId,
approvedNewDirectoryCreation,
agent,
yolo,
token
})
switch (result.type) {
case 'success':
@@ -22,6 +22,11 @@ describe('parseCodexCliOverrides', () => {
approvalPolicy: 'on-request'
});
expect(parseCodexCliOverrides(['--yolo'])).toEqual({
sandbox: 'danger-full-access',
approvalPolicy: 'never'
});
expect(parseCodexCliOverrides(['--dangerously-bypass-approvals-and-sandbox'])).toEqual({
sandbox: 'danger-full-access',
approvalPolicy: 'never'
+6
View File
@@ -34,6 +34,12 @@ export function parseCodexCliOverrides(args?: string[]): CodexCliOverrides {
continue;
}
if (arg === '--yolo') {
overrides.approvalPolicy = 'never';
overrides.sandbox = 'danger-full-access';
continue;
}
if (arg === '--dangerously-bypass-approvals-and-sandbox') {
overrides.approvalPolicy = 'never';
overrides.sandbox = 'danger-full-access';
+7 -2
View File
@@ -186,6 +186,8 @@ export async function startDaemon(): Promise<void> {
logger.debugLargeJson('[DAEMON RUN] Spawning session', options);
const { directory, sessionId, machineId, approvedNewDirectoryCreation = true } = options;
const agent = options.agent ?? 'claude';
const yolo = options.yolo === true;
let directoryCreated = false;
try {
@@ -256,9 +258,9 @@ export async function startDaemon(): Promise<void> {
}
// Construct arguments for the CLI
const agentCommand = options.agent === 'codex'
const agentCommand = agent === 'codex'
? 'codex'
: options.agent === 'gemini'
: agent === 'gemini'
? 'gemini'
: 'claude';
const args = [
@@ -266,6 +268,9 @@ export async function startDaemon(): Promise<void> {
'--hapi-starting-mode', 'remote',
'--started-by', 'daemon'
];
if (yolo) {
args.push('--yolo');
}
// TODO: In future, sessionId could be used with --resume to continue existing sessions
// For now, we ignore it - each spawn creates a new session
+16 -3
View File
@@ -147,16 +147,29 @@ import { getCliArgs } from './utils/cliArgs'
} else if (subcommand === 'gemini') {
// Handle gemini command
try {
await import('./agent/runners/gemini');
const { runAgentSession } = await import('./agent/runners/runAgentSession');
let startedBy: 'daemon' | 'terminal' | undefined = undefined;
let yolo = false;
for (let i = 1; i < args.length; i++) {
if (args[i] === '--started-by') {
startedBy = args[++i] as 'daemon' | 'terminal';
} else if (args[i] === '--yolo') {
yolo = true;
}
}
if (yolo) {
const existingArgs = process.env.HAPPY_GEMINI_ARGS ?? process.env.GEMINI_ACP_ARGS ?? '';
if (!existingArgs.includes('--yolo')) {
const nextArgs = existingArgs.trim().length > 0
? `${existingArgs} --yolo`
: '--yolo';
process.env.HAPPY_GEMINI_ARGS = nextArgs;
}
}
await import('./agent/runners/gemini');
const { runAgentSession } = await import('./agent/runners/runAgentSession');
await initializeToken();
await authAndSetupMachineIfNeeded();
await runAgentSession({ agentType: 'gemini', startedBy });
@@ -122,6 +122,7 @@ export interface SpawnSessionOptions {
sessionId?: string;
approvedNewDirectoryCreation?: boolean;
agent?: 'claude' | 'codex' | 'gemini';
yolo?: boolean;
token?: string;
}
+16 -5
View File
@@ -42,6 +42,13 @@ export function useSwitchControls(opts: {
}, confirmationTimeoutMs);
}, [confirmationTimeoutMs, resetConfirmation]);
const readKeyString = useCallback((keyLike: unknown, prop: 'name' | 'sequence'): string | undefined => {
if (!keyLike || typeof keyLike !== 'object') return undefined;
if (!(prop in keyLike)) return undefined;
const value = (keyLike as Record<string, unknown>)[prop];
return typeof value === 'string' ? value : undefined;
}, []);
useEffect(() => {
return () => {
if (confirmationTimeoutRef.current) {
@@ -78,14 +85,18 @@ export function useSwitchControls(opts: {
return;
}
const sequence = typeof key.sequence === 'string' ? key.sequence : input;
const isKeyRelease = typeof sequence === 'string' && /^\u001b\[[0-9;]*:3u$/.test(sequence);
const csiUMatch = typeof sequence === 'string'
? sequence.match(/^\u001b\[(\d+)(?:;(\d+))?u$/)
const keySequence = readKeyString(key, 'sequence');
const keyName = readKeyString(key, 'name');
const sequence = keySequence ?? input;
const sequenceString = typeof sequence === 'string' ? sequence : '';
const isKeyRelease = sequenceString.length > 0
&& /^\u001b\[[0-9;]*:3u$/.test(sequenceString);
const csiUMatch = sequenceString.length > 0
? sequenceString.match(/^\u001b\[(\d+)(?:;(\d+))?u$/)
: null;
const csiUCodepoint = csiUMatch ? Number(csiUMatch[1]) : null;
const isCsiUSpace = csiUCodepoint === 32;
const isSpace = Boolean(onSwitch) && !isKeyRelease && (input === ' ' || key.name === 'space' || isCsiUSpace);
const isSpace = Boolean(onSwitch) && !isKeyRelease && (input === ' ' || keyName === 'space' || isCsiUSpace);
const hasPrintableInput = typeof input === 'string' && input.length > 0;
if (isSpace) {
+7 -2
View File
@@ -652,10 +652,15 @@ export class SyncEngine {
async spawnSession(
machineId: string,
directory: string,
agent: 'claude' | 'codex' | 'gemini' = 'claude'
agent: 'claude' | 'codex' | 'gemini' = 'claude',
yolo?: boolean
): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> {
try {
const result = await this.machineRpc(machineId, 'spawn-happy-session', { type: 'spawn-in-directory', directory, agent })
const result = await this.machineRpc(
machineId,
'spawn-happy-session',
{ type: 'spawn-in-directory', directory, agent, yolo }
)
if (result && typeof result === 'object') {
const obj = result as Record<string, unknown>
if (obj.type === 'success' && typeof obj.sessionId === 'string') {
+8 -2
View File
@@ -5,7 +5,8 @@ import type { WebAppEnv } from '../middleware/auth'
const spawnBodySchema = z.object({
directory: z.string().min(1),
agent: z.enum(['claude', 'codex', 'gemini']).optional()
agent: z.enum(['claude', 'codex', 'gemini']).optional(),
yolo: z.boolean().optional()
})
export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Hono<WebAppEnv> {
@@ -39,7 +40,12 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho
return c.json({ error: 'Invalid body' }, 400)
}
const result = await engine.spawnSession(machineId, parsed.data.directory, parsed.data.agent)
const result = await engine.spawnSession(
machineId,
parsed.data.directory,
parsed.data.agent,
parsed.data.yolo
)
return c.json(result)
})
+7 -2
View File
@@ -227,10 +227,15 @@ export class ApiClient {
return await this.request<MachinesResponse>('/api/machines')
}
async spawnSession(machineId: string, directory: string, agent?: 'claude' | 'codex' | 'gemini'): Promise<SpawnResponse> {
async spawnSession(
machineId: string,
directory: string,
agent?: 'claude' | 'codex' | 'gemini',
yolo?: boolean
): Promise<SpawnResponse> {
return await this.request<SpawnResponse>(`/api/machines/${encodeURIComponent(machineId)}/spawn`, {
method: 'POST',
body: JSON.stringify({ directory, agent })
body: JSON.stringify({ directory, agent, yolo })
})
}
}
+30
View File
@@ -30,6 +30,7 @@ export function NewSession(props: {
const [machineId, setMachineId] = useState<string | null>(null)
const [directory, setDirectory] = useState('')
const [agent, setAgent] = useState<AgentType>('claude')
const [yoloMode, setYoloMode] = useState(false)
const [error, setError] = useState<string | null>(null)
// Initialize with last used machine or first available
@@ -83,6 +84,7 @@ export function NewSession(props: {
machineId,
directory: directory.trim(),
agent,
yolo: yoloMode
})
if (result.type === 'success') {
@@ -194,6 +196,34 @@ export function NewSession(props: {
</div>
</div>
{/* YOLO Mode */}
<div className="flex flex-col gap-1.5 px-3 py-3">
<label className="text-xs font-medium text-[var(--app-hint)]">
YOLO mode
</label>
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col">
<span className="text-sm text-[var(--app-fg)]">
Bypass approvals and sandbox
</span>
<span className="text-xs text-[var(--app-hint)]">
Uses dangerous agent flags when spawning.
</span>
</div>
<label className="relative inline-flex h-5 w-9 items-center">
<input
type="checkbox"
checked={yoloMode}
onChange={(e) => setYoloMode(e.target.checked)}
disabled={isFormDisabled}
className="peer sr-only"
/>
<span className="absolute inset-0 rounded-full bg-[var(--app-border)] transition-colors peer-checked:bg-[var(--app-link)] peer-disabled:opacity-50" />
<span className="absolute left-0.5 h-4 w-4 rounded-full bg-[var(--app-bg)] transition-transform peer-checked:translate-x-4 peer-disabled:opacity-50" />
</label>
</div>
</div>
{/* Error Message */}
{(error ?? spawnError) ? (
<div className="px-3 py-2 text-sm text-red-600">
+2 -1
View File
@@ -7,6 +7,7 @@ type SpawnInput = {
machineId: string
directory: string
agent?: 'claude' | 'codex' | 'gemini'
yolo?: boolean
}
export function useSpawnSession(api: ApiClient | null): {
@@ -21,7 +22,7 @@ export function useSpawnSession(api: ApiClient | null): {
if (!api) {
throw new Error('API unavailable')
}
return await api.spawnSession(input.machineId, input.directory, input.agent)
return await api.spawnSession(input.machineId, input.directory, input.agent, input.yolo)
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.sessions })