From 5706741b96071417e7eb85822d36bbf41a09a232 Mon Sep 17 00:00:00 2001 From: caseyjkey Date: Fri, 27 Feb 2026 15:36:21 -0800 Subject: [PATCH 1/6] fix(cli): retry tmux window creation with explicit index on conflict When creating a new window in an existing tmux session, tmux can fail with "create window failed: index N in use" due to window index allocation issues. The previous retry logic just retried the same command, which would fail again if tmux kept trying to use the same conflicting index. This fix: - Adds extractTmuxWindowIndexConflict() to parse the conflicting index - Adds getWindowIndices() to query session for existing window indices - Adds findAvailableWindowIndex() to find an available index (gap or next) - Updates retry logic to explicitly target an available index on retry On first attempt: tmux new-window -t session (auto-assign) On retry: tmux new-window -t session:3 (explicit available index) --- .../src/integrations/tmux/TmuxUtilities.ts | 90 +++++++++++++- .../tmux/env.extractIndex.test.ts | 52 ++++++++ apps/cli/src/integrations/tmux/env.ts | 12 ++ .../tmux/tmux.spawnAndEnv.test.ts | 116 ++++++++++++++++++ 4 files changed, 266 insertions(+), 4 deletions(-) create mode 100644 apps/cli/src/integrations/tmux/env.extractIndex.test.ts diff --git a/apps/cli/src/integrations/tmux/TmuxUtilities.ts b/apps/cli/src/integrations/tmux/TmuxUtilities.ts index b4eaa474b..c6f9514af 100644 --- a/apps/cli/src/integrations/tmux/TmuxUtilities.ts +++ b/apps/cli/src/integrations/tmux/TmuxUtilities.ts @@ -4,6 +4,7 @@ import { logger } from '@/ui/logger'; import { buildPosixShellCommand, + extractTmuxWindowIndexConflict, isTmuxWindowIndexConflict, normalizeExitCode, readNonNegativeIntegerEnv, @@ -517,21 +518,33 @@ export class TmuxUtilities { // // Note: tmux can fail with `create window failed: index N in use` when multiple // clients concurrently create windows in the same session (tmux does not always - // auto-retry the window index allocation). Retry a few times to make concurrent - // session starts robust. + // auto-retry the window index allocation). Retry a few times with explicit index + // selection to make concurrent session starts robust. const maxAttempts = readPositiveIntegerEnv('HAPPIER_CLI_TMUX_CREATE_WINDOW_MAX_ATTEMPTS', 3); const retryDelayMs = readNonNegativeIntegerEnv('HAPPIER_CLI_TMUX_CREATE_WINDOW_RETRY_DELAY_MS', 25); let createResult: TmuxCommandResult | null = null; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - createResult = await this.executeTmuxCommand(createWindowArgs); + // On first attempt, use default args (let tmux auto-assign index). + // On retry, query for an available index and explicitly target it. + let currentArgs = createWindowArgs; + if (attempt > 1) { + const availableIndex = await this.findAvailableWindowIndex(sessionName); + logger.debug(`[TMUX] Retrying with explicit window index ${availableIndex}`); + // Rebuild args with explicit target: session:index + const explicitTarget = `${sessionName}:${availableIndex}`; + currentArgs = this.buildArgsWithExplicitTarget(createWindowArgs, explicitTarget); + } + + createResult = await this.executeTmuxCommand(currentArgs); if (createResult && createResult.returncode === 0) break; const stderr = createResult?.stderr; const shouldRetry = attempt < maxAttempts && isTmuxWindowIndexConflict(stderr); if (!shouldRetry) break; - logger.debug(`[TMUX] new-window failed with window index conflict; retrying (attempt ${attempt}/${maxAttempts})`); + const conflictIndex = extractTmuxWindowIndexConflict(stderr); + logger.debug(`[TMUX] new-window failed with window index conflict (index ${conflictIndex ?? 'unknown'}); retrying (attempt ${attempt}/${maxAttempts})`); if (retryDelayMs > 0) { await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); } @@ -577,6 +590,32 @@ export class TmuxUtilities { } } + /** + * Build new-window args with an explicit target (session:index). + * Replaces the existing -t argument with the explicit target. + */ + private buildArgsWithExplicitTarget(originalArgs: string[], explicitTarget: string): string[] { + const newArgs: string[] = []; + let skipNext = false; + + for (let i = 0; i < originalArgs.length; i += 1) { + if (skipNext) { + skipNext = false; + continue; + } + const arg = originalArgs[i]; + if (arg === '-t') { + // Skip -t and its value, add our explicit target instead + skipNext = true; + newArgs.push('-t', explicitTarget); + } else { + newArgs.push(arg); + } + } + + return newArgs; + } + /** * Get session info for a given session identifier string */ @@ -633,4 +672,47 @@ export class TmuxUtilities { .map((line) => line.trim()) .filter(Boolean); } + + /** + * Get the set of window indices currently in use for a session. + * Returns an empty set if the session cannot be queried. + */ + async getWindowIndices(sessionName?: string): Promise> { + const targetSession = sessionName || this.sessionName; + const result = await this.executeTmuxCommand(['list-windows', '-t', targetSession, '-F', '#{window_index}']); + + if (!result || result.returncode !== 0) { + return new Set(); + } + + const indices = new Set(); + for (const line of result.stdout.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + const parsed = Number.parseInt(trimmed, 10); + if (Number.isFinite(parsed)) { + indices.add(parsed); + } + } + return indices; + } + + /** + * Find an available window index for the session. + * Starts from base-index (default 0) and finds the first gap or next available. + */ + async findAvailableWindowIndex(sessionName?: string): Promise { + const usedIndices = await this.getWindowIndices(sessionName); + + if (usedIndices.size === 0) { + return 0; + } + + // Find the first gap, or use max + 1 + let candidate = 0; + while (usedIndices.has(candidate)) { + candidate += 1; + } + return candidate; + } } diff --git a/apps/cli/src/integrations/tmux/env.extractIndex.test.ts b/apps/cli/src/integrations/tmux/env.extractIndex.test.ts new file mode 100644 index 000000000..7377749f9 --- /dev/null +++ b/apps/cli/src/integrations/tmux/env.extractIndex.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { extractTmuxWindowIndexConflict, isTmuxWindowIndexConflict } from './env'; + +describe('isTmuxWindowIndexConflict', () => { + it('returns true for tmux index conflict messages', () => { + expect(isTmuxWindowIndexConflict('create window failed: index 0 in use.')).toBe(true); + expect(isTmuxWindowIndexConflict('create window failed: index 1 in use.')).toBe(true); + expect(isTmuxWindowIndexConflict('create window failed: index 42 in use.')).toBe(true); + }); + + it('returns true regardless of case', () => { + expect(isTmuxWindowIndexConflict('INDEX 5 IN USE')).toBe(true); + expect(isTmuxWindowIndexConflict('Index 5 In Use')).toBe(true); + }); + + it('returns false for non-conflict messages', () => { + expect(isTmuxWindowIndexConflict('session not found')).toBe(false); + expect(isTmuxWindowIndexConflict('no server running')).toBe(false); + expect(isTmuxWindowIndexConflict('')).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isTmuxWindowIndexConflict(undefined)).toBe(false); + }); +}); + +describe('extractTmuxWindowIndexConflict', () => { + it('extracts window index from tmux error messages', () => { + expect(extractTmuxWindowIndexConflict('create window failed: index 0 in use.')).toBe(0); + expect(extractTmuxWindowIndexConflict('create window failed: index 1 in use.')).toBe(1); + expect(extractTmuxWindowIndexConflict('create window failed: index 42 in use.')).toBe(42); + }); + + it('extracts index regardless of case', () => { + expect(extractTmuxWindowIndexConflict('INDEX 5 IN USE')).toBe(5); + expect(extractTmuxWindowIndexConflict('Index 5 In Use')).toBe(5); + }); + + it('returns null for non-conflict messages', () => { + expect(extractTmuxWindowIndexConflict('session not found')).toBeNull(); + expect(extractTmuxWindowIndexConflict('no server running')).toBeNull(); + expect(extractTmuxWindowIndexConflict('')).toBeNull(); + }); + + it('returns null for undefined', () => { + expect(extractTmuxWindowIndexConflict(undefined)).toBeNull(); + }); + + it('handles extra whitespace', () => { + expect(extractTmuxWindowIndexConflict('create window failed: index 3 in use.')).toBe(3); + }); +}); diff --git a/apps/cli/src/integrations/tmux/env.ts b/apps/cli/src/integrations/tmux/env.ts index c3f146771..5ed1a927a 100644 --- a/apps/cli/src/integrations/tmux/env.ts +++ b/apps/cli/src/integrations/tmux/env.ts @@ -19,6 +19,18 @@ export function isTmuxWindowIndexConflict(stderr: string | undefined): boolean { return /index\s+\d+\s+in\s+use/i.test(stderr ?? ''); } +/** + * Extract the conflicting window index from tmux error message. + * Returns null if no index can be parsed. + */ +export function extractTmuxWindowIndexConflict(stderr: string | undefined): number | null { + if (!stderr) return null; + const match = /index\s+(\d+)\s+in\s+use/i.exec(stderr); + if (!match || !match[1]) return null; + const parsed = Number.parseInt(match[1], 10); + return Number.isFinite(parsed) ? parsed : null; +} + export function normalizeExitCode(code: number | null): number { // Node passes `code === null` when the process was terminated by a signal. // Preserve failure semantics rather than treating it as success. diff --git a/apps/cli/src/integrations/tmux/tmux.spawnAndEnv.test.ts b/apps/cli/src/integrations/tmux/tmux.spawnAndEnv.test.ts index 14fcf90ea..0db59000c 100644 --- a/apps/cli/src/integrations/tmux/tmux.spawnAndEnv.test.ts +++ b/apps/cli/src/integrations/tmux/tmux.spawnAndEnv.test.ts @@ -226,6 +226,53 @@ describe('TmuxUtilities.spawnInTmux', () => { expect(newWindowCalls.length).toBeGreaterThanOrEqual(2); }); + it('uses explicit window index on retry after conflict', async () => { + // This test verifies that on retry, we query for available indices + // and use an explicit target like "session:index" + class ConflictThenExplicitIndexTmuxUtilities extends FakeTmuxUtilities { + private newWindowAttempts = 0; + public readonly targetArguments: string[] = []; + + override async executeTmuxCommand(cmd: string[], session?: string): Promise { + // Track list-windows calls for window indices + if (cmd[0] === 'list-windows' && cmd.includes('#{window_index}')) { + // Return indices 0, 1, 2 (so next available is 3) + return { returncode: 0, stdout: '0\n1\n2\n', stderr: '', command: cmd }; + } + + if (cmd[0] !== 'new-window') { + return super.executeTmuxCommand(cmd, session); + } + + this.newWindowAttempts += 1; + this.calls.push({ cmd, session }); + + // Track the -t argument + const tIndex = cmd.indexOf('-t'); + if (tIndex >= 0 && tIndex + 1 < cmd.length) { + this.targetArguments.push(cmd[tIndex + 1]!); + } + + if (this.newWindowAttempts === 1) { + // First attempt fails with index 0 in use + return { returncode: 1, stdout: '', stderr: 'create window failed: index 0 in use.', command: cmd }; + } + // Second attempt succeeds + return { returncode: 0, stdout: '4242\n', stderr: '', command: cmd }; + } + } + + const tmux = new ConflictThenExplicitIndexTmuxUtilities(); + const result = await tmux.spawnInTmux(['echo', 'hello'], { sessionName: 'my-session', windowName: 'my-window' }, {}); + + expect(result.success).toBe(true); + expect(tmux.targetArguments.length).toBe(2); + // First attempt: just session name (let tmux auto-assign) + expect(tmux.targetArguments[0]).toBe('my-session'); + // Second attempt: explicit session:index + expect(tmux.targetArguments[1]).toBe('my-session:3'); + }); + it('returns an error when tmux new-window output is not a numeric pane PID', async () => { class InvalidPidTmuxUtilities extends FakeTmuxUtilities { override async executeTmuxCommand(cmd: string[], session?: string): Promise { @@ -244,3 +291,72 @@ describe('TmuxUtilities.spawnInTmux', () => { expect(result.error).toMatch(/PID/i); }); }); + +describe('TmuxUtilities.getWindowIndices', () => { + class FakeTmuxWithWindowIndices extends TmuxUtilities { + public override async executeTmuxCommand(cmd: string[]): Promise { + if (cmd[0] === 'list-windows') { + return { returncode: 0, stdout: '0\n2\n5\n', stderr: '', command: cmd }; + } + return { returncode: 0, stdout: '', stderr: '', command: cmd }; + } + } + + it('returns a set of window indices', async () => { + const tmux = new FakeTmuxWithWindowIndices(); + const indices = await tmux.getWindowIndices('my-session'); + expect(indices).toEqual(new Set([0, 2, 5])); + }); +}); + +describe('TmuxUtilities.findAvailableWindowIndex', () => { + class FakeTmuxWithNoWindows extends TmuxUtilities { + public override async executeTmuxCommand(cmd: string[]): Promise { + if (cmd[0] === 'list-windows') { + return { returncode: 0, stdout: '', stderr: '', command: cmd }; + } + return { returncode: 0, stdout: '', stderr: '', command: cmd }; + } + } + + class FakeTmuxWithWindows extends TmuxUtilities { + private windowIndices: number[]; + + constructor(indices: number[]) { + super('test'); + this.windowIndices = indices; + } + + public override async executeTmuxCommand(cmd: string[]): Promise { + if (cmd[0] === 'list-windows') { + const stdout = this.windowIndices.join('\n'); + return { returncode: 0, stdout, stderr: '', command: cmd }; + } + return { returncode: 0, stdout: '', stderr: '', command: cmd }; + } + } + + it('returns 0 when session has no windows', async () => { + const tmux = new FakeTmuxWithNoWindows(); + const index = await tmux.findAvailableWindowIndex('my-session'); + expect(index).toBe(0); + }); + + it('finds first gap in window indices', async () => { + const tmux = new FakeTmuxWithWindows([0, 1, 3]); // gap at 2 + const index = await tmux.findAvailableWindowIndex('my-session'); + expect(index).toBe(2); + }); + + it('returns next index when no gaps', async () => { + const tmux = new FakeTmuxWithWindows([0, 1, 2]); + const index = await tmux.findAvailableWindowIndex('my-session'); + expect(index).toBe(3); + }); + + it('finds gap at start when index 0 is missing', async () => { + const tmux = new FakeTmuxWithWindows([1, 2, 3]); + const index = await tmux.findAvailableWindowIndex('my-session'); + expect(index).toBe(0); + }); +}); From 98f259bb76e632f447cc62afcb23a2b5a9e0318d Mon Sep 17 00:00:00 2001 From: caseyjkey Date: Fri, 27 Feb 2026 20:18:20 -0800 Subject: [PATCH 2/6] chore(cli): bump version to 0.1.1 for daemon restart Bumping version so daemon detects code change and restarts with the tmux window index conflict fix from previous commit. --- apps/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 2f71c4a63..715b36b2c 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@happier-dev/cli", - "version": "0.1.0", + "version": "0.1.1", "description": "Mobile and Web client for Claude Code and Codex", "author": "Leeroy Brun ", "license": "MIT", From 239d7b97bc2ea8bb0692bdafdd609d9b92578007 Mon Sep 17 00:00:00 2001 From: caseyjkey Date: Mon, 2 Mar 2026 21:10:17 -0800 Subject: [PATCH 3/6] fix(cli): prevent session respawn after stop When stopping a session via the app (killSession RPC) or Ctrl-C, the daemon would sometimes respawn it after ~60 seconds. This happened because the killSession RPC handler exited without notifying the daemon, so the heartbeat loop detected a missing process and triggered respawn. Fix: Call stopDaemonSession(sessionId) in the killSession handler before exiting. This marks the session as "stop requested" in the respawn manager, preventing unwanted respawns. Discovered during mobile development testing on Termux. --- .../agent/runtime/runStandardAcpProvider.ts | 2 +- .../src/agent/tools/normalization/index.ts | 2 +- apps/cli/src/backends/auggie/acp/transport.ts | 4 ++-- .../backends/claude/claudeRemoteLauncher.ts | 10 ++++++++ apps/cli/src/backends/claude/runClaude.ts | 14 +++++------ apps/cli/src/backends/codex/runCodex.ts | 2 +- .../cli/src/backends/copilot/acp/transport.ts | 4 ++-- apps/cli/src/backends/gemini/acp/backend.ts | 2 +- apps/cli/src/backends/gemini/acp/transport.ts | 6 ++--- apps/cli/src/backends/gemini/runGemini.ts | 2 +- .../createGeminiBackendMessageHandler.ts | 2 +- .../gemini/runtime/geminiTurnMessageState.ts | 2 +- .../gemini/utils/permissionHandler.ts | 2 +- .../src/backends/gemini/utils/promptUtils.ts | 4 ++-- apps/cli/src/backends/kilo/acp/transport.ts | 4 ++-- .../src/backends/opencode/acp/transport.ts | 4 ++-- .../src/integrations/tmux/TmuxUtilities.ts | 24 +++++++++++++++++-- apps/cli/src/rpc/handlers/killSession.ts | 17 +++++++++++-- apps/cli/src/ui/tty/resolveHasTTY.test.ts | 10 ++++++-- apps/cli/src/ui/tty/resolveHasTTY.ts | 4 +++- eas.json | 21 ++++++++++++++++ 21 files changed, 107 insertions(+), 35 deletions(-) create mode 100644 eas.json diff --git a/apps/cli/src/agent/runtime/runStandardAcpProvider.ts b/apps/cli/src/agent/runtime/runStandardAcpProvider.ts index 3d7210cd6..65c54cc35 100644 --- a/apps/cli/src/agent/runtime/runStandardAcpProvider.ts +++ b/apps/cli/src/agent/runtime/runStandardAcpProvider.ts @@ -309,7 +309,7 @@ export async function runStandardAcpProvider( }; session.rpcHandlerManager.registerHandler('abort', handleAbort); - registerKillSessionHandlerFn(session.rpcHandlerManager, handleKillSession); + registerKillSessionHandlerFn(session.rpcHandlerManager, session.sessionId, handleKillSession); const sendReady = config.createSendReady ? config.createSendReady({ session, api }) diff --git a/apps/cli/src/agent/tools/normalization/index.ts b/apps/cli/src/agent/tools/normalization/index.ts index 8d9c59d26..5640d7fc9 100644 --- a/apps/cli/src/agent/tools/normalization/index.ts +++ b/apps/cli/src/agent/tools/normalization/index.ts @@ -276,7 +276,7 @@ export function canonicalizeToolNameV2(opts: { if (name === 'GeminiReasoning' || name === 'CodexReasoning' || lower === 'think') return 'Reasoning'; if (lower === 'exit_plan_mode') return 'ExitPlanMode'; if (lower === 'askuserquestion' || lower === 'ask_user_question') return 'AskUserQuestion'; - if (lower === 'mcp__happier__change_title' || lower === 'mcp__happy__change_title') return 'change_title'; + if (lower === 'mcp__happier__change_title' || lower === 'mcp__happier__change_title') return 'change_title'; return name; } diff --git a/apps/cli/src/backends/auggie/acp/transport.ts b/apps/cli/src/backends/auggie/acp/transport.ts index ec543c5a2..477aad48f 100644 --- a/apps/cli/src/backends/auggie/acp/transport.ts +++ b/apps/cli/src/backends/auggie/acp/transport.ts @@ -33,8 +33,8 @@ const AUGGIE_TOOL_PATTERNS: readonly ToolPatternWithInputFields[] = [ patterns: [ 'change_title', 'change-title', - 'happy__change_title', - 'mcp__happy__change_title', + 'happier__change_title', + 'mcp__happier__change_title', 'happier__change_title', 'mcp__happier__change_title', ], diff --git a/apps/cli/src/backends/claude/claudeRemoteLauncher.ts b/apps/cli/src/backends/claude/claudeRemoteLauncher.ts index b3a45e257..9345e4576 100644 --- a/apps/cli/src/backends/claude/claudeRemoteLauncher.ts +++ b/apps/cli/src/backends/claude/claudeRemoteLauncher.ts @@ -27,6 +27,7 @@ import { resolveHasTTY } from '@/ui/tty/resolveHasTTY'; import { createNonBlockingStdout } from '@/ui/ink/nonBlockingStdout'; import { updateMetadataBestEffort } from '@/api/session/sessionWritesBestEffort'; import { sendReadyWithPushNotification } from '@/agent/runtime/sendReadyWithPushNotification'; +import { stopDaemonSession } from '@/daemon/controlClient'; import { dirname, join } from 'node:path'; import { getProjectPath } from './utils/path'; @@ -152,6 +153,15 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | if (!exitReason) { exitReason = 'exit'; } + // Notify daemon to stop respawning this session + if (session.sessionId) { + try { + await stopDaemonSession(session.sessionId); + logger.debug('[remote]: Notified daemon to stop session'); + } catch (e) { + logger.debug('[remote]: Failed to notify daemon of stop:', e); + } + } await abort(); }, onSwitchToLocal: () => { diff --git a/apps/cli/src/backends/claude/runClaude.ts b/apps/cli/src/backends/claude/runClaude.ts index 8edbc0a11..91dc3fb67 100644 --- a/apps/cli/src/backends/claude/runClaude.ts +++ b/apps/cli/src/backends/claude/runClaude.ts @@ -795,7 +795,7 @@ export async function runClaude(credentials: Credentials, options: StartOptions onTerminate: cleanup, }); - registerKillSessionHandler(session.rpcHandlerManager, async () => { + registerKillSessionHandler(session.rpcHandlerManager, session.sessionId, async () => { terminationHandlers.requestTermination({ kind: 'killSession' }); await terminationHandlers.whenTerminated; }); @@ -811,7 +811,7 @@ export async function runClaude(credentials: Credentials, options: StartOptions messageQueue, session, pushSender: api.push(), - allowedTools: happyServer.toolNames.map(toolName => `mcp__happy__${toolName}`), + allowedTools: happyServer.toolNames.map(toolName => `mcp__happier__${toolName}`), onModeChange: (newMode) => { session.sendSessionEvent({ type: 'switch', mode: newMode }); updateAgentStateBestEffort( @@ -839,8 +839,8 @@ export async function runClaude(credentials: Credentials, options: StartOptions }, mcpServers: { ...extractedMcp.mcpServers, - // Keep Happy MCP server last so a user-provided "happy" entry cannot override it. - happy: { + // Keep Happier MCP server last so a user-provided "happier" entry cannot override it. + happier: { type: 'http' as const, url: happyServer.url, }, @@ -1337,7 +1337,7 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO startingMode: options.startingMode, startedBy: options.startedBy, messageQueue, - allowedTools: happyServer.toolNames.map(toolName => `mcp__happy__${toolName}`), + allowedTools: happyServer.toolNames.map(toolName => `mcp__happier__${toolName}`), onModeChange: (newMode) => { artifacts.deferredSession.sendSessionEvent({ type: 'switch', mode: newMode }); updateAgentStateBestEffort( @@ -1369,7 +1369,7 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO }, mcpServers: { ...extractedMcp.mcpServers, - happy: { + happier: { type: 'http' as const, url: happyServer.url, }, @@ -1435,7 +1435,7 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO }, }); - registerKillSessionHandler(coordinator.artifacts.deferredSession.rpcHandlerManager, async () => { + registerKillSessionHandler(coordinator.artifacts.deferredSession.rpcHandlerManager, coordinator.artifacts.deferredSession.sessionId, async () => { terminationHandlers.requestTermination({ kind: 'killSession' }); await terminationHandlers.whenTerminated; }); diff --git a/apps/cli/src/backends/codex/runCodex.ts b/apps/cli/src/backends/codex/runCodex.ts index cb05af909..62b3688ce 100644 --- a/apps/cli/src/backends/codex/runCodex.ts +++ b/apps/cli/src/backends/codex/runCodex.ts @@ -689,7 +689,7 @@ export async function runCodex(opts: { // Register abort handler session.rpcHandlerManager.registerHandler('abort', handleAbort); - registerKillSessionHandler(session.rpcHandlerManager, handleKillSession); + registerKillSessionHandler(session.rpcHandlerManager, session.sessionId, handleKillSession); // // Initialize Ink UI diff --git a/apps/cli/src/backends/copilot/acp/transport.ts b/apps/cli/src/backends/copilot/acp/transport.ts index 2df60a407..4c5c5837c 100644 --- a/apps/cli/src/backends/copilot/acp/transport.ts +++ b/apps/cli/src/backends/copilot/acp/transport.ts @@ -42,8 +42,8 @@ const COPILOT_TOOL_PATTERNS: readonly ToolPatternWithInputFields[] = [ patterns: [ 'change_title', 'change-title', - 'happy__change_title', - 'mcp__happy__change_title', + 'happier__change_title', + 'mcp__happier__change_title', 'happier__change_title', 'mcp__happier__change_title', ], diff --git a/apps/cli/src/backends/gemini/acp/backend.ts b/apps/cli/src/backends/gemini/acp/backend.ts index 881e99a96..349088096 100644 --- a/apps/cli/src/backends/gemini/acp/backend.ts +++ b/apps/cli/src/backends/gemini/acp/backend.ts @@ -186,7 +186,7 @@ export function createGeminiBackend(options: GeminiBackendOptions): GeminiBacken return lower.includes('change_title') || lower.includes('change title') || lower.includes('set title') || - lower.includes('mcp__happy__change_title') || + lower.includes('mcp__happier__change_title') || lower.includes('mcp__happier__change_title'); }, }; diff --git a/apps/cli/src/backends/gemini/acp/transport.ts b/apps/cli/src/backends/gemini/acp/transport.ts index 1354648ae..deac230aa 100644 --- a/apps/cli/src/backends/gemini/acp/transport.ts +++ b/apps/cli/src/backends/gemini/acp/transport.ts @@ -64,8 +64,8 @@ const GEMINI_TOOL_PATTERNS: ToolPatternWithInputFields[] = [ patterns: [ 'change_title', 'change-title', - 'happy__change_title', - 'mcp__happy__change_title', + 'happier__change_title', + 'mcp__happier__change_title', 'happier__change_title', 'mcp__happier__change_title', ], @@ -280,7 +280,7 @@ export class GeminiTransport implements TransportHandler { input: Record, _context: ToolNameContext ): string { - // 0. Normalize direct legacy aliases (for example happy__change_title) to canonical names. + // 0. Normalize direct legacy aliases (for example happier__change_title) to canonical names. const directToolName = findToolNameFromId(toolName, GEMINI_TOOL_PATTERNS, { preferLongestMatch: true }); if (directToolName) return directToolName; diff --git a/apps/cli/src/backends/gemini/runGemini.ts b/apps/cli/src/backends/gemini/runGemini.ts index f21ac6f02..23691aae7 100644 --- a/apps/cli/src/backends/gemini/runGemini.ts +++ b/apps/cli/src/backends/gemini/runGemini.ts @@ -498,7 +498,7 @@ export async function runGemini(opts: { }; session.rpcHandlerManager.registerHandler('abort', handleAbort); - registerKillSessionHandler(session.rpcHandlerManager, handleKillSession); + registerKillSessionHandler(session.rpcHandlerManager, session.sessionId, handleKillSession); // Create permission handler for tool approval (variable declared earlier for onSessionSwap) permissionHandler = createProviderEnforcedPermissionHandler({ diff --git a/apps/cli/src/backends/gemini/runtime/createGeminiBackendMessageHandler.ts b/apps/cli/src/backends/gemini/runtime/createGeminiBackendMessageHandler.ts index 1116c33a9..6e043dcce 100644 --- a/apps/cli/src/backends/gemini/runtime/createGeminiBackendMessageHandler.ts +++ b/apps/cli/src/backends/gemini/runtime/createGeminiBackendMessageHandler.ts @@ -130,7 +130,7 @@ export function createGeminiBackendMessageHandler(params: { case 'tool-result': { if ( msg.toolName === 'change_title' || - msg.toolName === 'happy__change_title' || + msg.toolName === 'happier__change_title' || msg.callId?.includes('change_title') || msg.toolName === 'happier__change_title' ) { diff --git a/apps/cli/src/backends/gemini/runtime/geminiTurnMessageState.ts b/apps/cli/src/backends/gemini/runtime/geminiTurnMessageState.ts index 97ce1ac0d..30e7843bb 100644 --- a/apps/cli/src/backends/gemini/runtime/geminiTurnMessageState.ts +++ b/apps/cli/src/backends/gemini/runtime/geminiTurnMessageState.ts @@ -30,7 +30,7 @@ export function resetGeminiTurnMessageStateForPrompt( state.taskStartedSent = false; state.pendingChangeTitle = prompt.includes('change_title') || - prompt.includes('happy__change_title') || + prompt.includes('happier__change_title') || prompt.includes('happier__change_title'); state.changeTitleCompleted = false; } diff --git a/apps/cli/src/backends/gemini/utils/permissionHandler.ts b/apps/cli/src/backends/gemini/utils/permissionHandler.ts index f56c1cc1b..ced33930c 100644 --- a/apps/cli/src/backends/gemini/utils/permissionHandler.ts +++ b/apps/cli/src/backends/gemini/utils/permissionHandler.ts @@ -21,7 +21,7 @@ export class GeminiPermissionHandler extends CodexLikePermissionHandler { super({ session, logPrefix: '[Gemini]', onAbortRequested: opts?.onAbortRequested ?? null }); // Always-auto-approve safe internal tools that don't perform external side effects. this.alwaysAutoApproveToolNameIncludes = [ - 'happy__change_title', + 'happier__change_title', 'happier__change_title', 'geminireasoning', 'codexreasoning', diff --git a/apps/cli/src/backends/gemini/utils/promptUtils.ts b/apps/cli/src/backends/gemini/utils/promptUtils.ts index 30bf370d0..e9257a416 100644 --- a/apps/cli/src/backends/gemini/utils/promptUtils.ts +++ b/apps/cli/src/backends/gemini/utils/promptUtils.ts @@ -13,8 +13,8 @@ export function hasChangeTitleInstruction(prompt: string): boolean { const lower = prompt.toLowerCase(); return lower.includes('change_title') || - lower.includes('happy__change_title') || - lower.includes('mcp__happy__change_title') || + lower.includes('happier__change_title') || + lower.includes('mcp__happier__change_title') || lower.includes('happier__change_title') || lower.includes('mcp__happier__change_title'); } diff --git a/apps/cli/src/backends/kilo/acp/transport.ts b/apps/cli/src/backends/kilo/acp/transport.ts index 89957693c..4ba15c1ba 100644 --- a/apps/cli/src/backends/kilo/acp/transport.ts +++ b/apps/cli/src/backends/kilo/acp/transport.ts @@ -42,8 +42,8 @@ const KILO_TOOL_PATTERNS: readonly ToolPatternWithInputFields[] = [ patterns: [ 'change_title', 'change-title', - 'happy__change_title', - 'mcp__happy__change_title', + 'happier__change_title', + 'mcp__happier__change_title', 'happier__change_title', 'mcp__happier__change_title', ], diff --git a/apps/cli/src/backends/opencode/acp/transport.ts b/apps/cli/src/backends/opencode/acp/transport.ts index d842cdda4..9e437adb6 100644 --- a/apps/cli/src/backends/opencode/acp/transport.ts +++ b/apps/cli/src/backends/opencode/acp/transport.ts @@ -48,8 +48,8 @@ const OPENCODE_TOOL_PATTERNS: readonly ToolPatternWithInputFields[] = [ patterns: [ 'change_title', 'change-title', - 'happy__change_title', - 'mcp__happy__change_title', + 'happier__change_title', + 'mcp__happier__change_title', 'happier__change_title', 'mcp__happier__change_title', ], diff --git a/apps/cli/src/integrations/tmux/TmuxUtilities.ts b/apps/cli/src/integrations/tmux/TmuxUtilities.ts index c6f9514af..8373a864c 100644 --- a/apps/cli/src/integrations/tmux/TmuxUtilities.ts +++ b/apps/cli/src/integrations/tmux/TmuxUtilities.ts @@ -270,15 +270,24 @@ export class TmuxUtilities { return result !== null && result.returncode === 0; } + /** + * Check if a session exists + */ + async sessionExists(sessionName?: string): Promise { + const targetSession = sessionName || this.sessionName; + const result = await this.executeTmuxCommand(['has-session', '-t', targetSession]); + return result !== null && result.returncode === 0; + } + /** * Ensure session exists, create if needed + * @returns true if session already existed, false if it was created */ async ensureSessionExists(sessionName?: string): Promise { const targetSession = sessionName || this.sessionName; // Check if session exists - const result = await this.executeTmuxCommand(['has-session', '-t', targetSession]); - if (result && result.returncode === 0) { + if (await this.sessionExists(targetSession)) { return true; } @@ -468,6 +477,9 @@ export class TmuxUtilities { const windowName = options.windowName || `happy-${Date.now()}`; + // Check if session already exists to avoid creating an extra empty window + const sessionAlreadyExisted = await this.sessionExists(sessionName); + // Ensure session exists await this.ensureSessionExists(sessionName); @@ -568,6 +580,14 @@ export class TmuxUtilities { logger.debug(`[TMUX] Spawned command in tmux session ${sessionName}, window ${windowName}, PID ${panePid}`); + // If session was just created, kill the empty window 0 that new-session created + if (!sessionAlreadyExisted) { + const killResult = await this.executeTmuxCommand(['kill-window', '-t', `${sessionName}:0`]); + if (killResult && killResult.returncode === 0) { + logger.debug(`[TMUX] Killed empty window 0 in newly created session ${sessionName}`); + } + } + // Return tmux session info and PID const sessionIdentifier: TmuxSessionIdentifier = { session: sessionName, diff --git a/apps/cli/src/rpc/handlers/killSession.ts b/apps/cli/src/rpc/handlers/killSession.ts index 9da176502..3e25df165 100644 --- a/apps/cli/src/rpc/handlers/killSession.ts +++ b/apps/cli/src/rpc/handlers/killSession.ts @@ -1,6 +1,7 @@ import type { RpcHandlerRegistrar } from "@/api/rpc/types"; import { logger } from "@/lib"; import { RPC_METHODS } from '@happier-dev/protocol/rpc'; +import { stopDaemonSession, checkIfDaemonRunningAndCleanupStaleState } from "@/daemon/controlClient"; interface KillSessionRequest { // No parameters needed @@ -14,16 +15,28 @@ interface KillSessionResponse { export function registerKillSessionHandler( rpcHandlerManager: RpcHandlerRegistrar, + sessionId: string, killThisHappier: () => Promise ) { rpcHandlerManager.registerHandler(RPC_METHODS.KILL_SESSION, async () => { logger.debug('Kill session request received'); + // Notify daemon to mark this session as stopped before we exit + // This prevents the respawn manager from respawning the session + if (await checkIfDaemonRunningAndCleanupStaleState()) { + try { + await stopDaemonSession(sessionId); + } catch (error) { + logger.debug('Failed to notify daemon about session stop', error); + // Don't block exit if daemon notification fails + } + } + // This will start the cleanup process void killThisHappier(); - // We should still be able to respond the the client, though they - // should optimistically assume the session is dead. + // We should still be able to respond to the client, though they + // should optimistically assume the session is dead return { success: true, message: 'Killing happier process' diff --git a/apps/cli/src/ui/tty/resolveHasTTY.test.ts b/apps/cli/src/ui/tty/resolveHasTTY.test.ts index c2b06e82a..0c6722a7c 100644 --- a/apps/cli/src/ui/tty/resolveHasTTY.test.ts +++ b/apps/cli/src/ui/tty/resolveHasTTY.test.ts @@ -1,13 +1,19 @@ import { describe, expect, it } from 'vitest'; describe('resolveHasTTY', () => { - it('requires both stdin/stdout TTY and blocks daemon-started sessions', async () => { + it('requires both stdin/stdout TTY, allows daemon-started sessions with TTY', async () => { const { resolveHasTTY } = await import('./resolveHasTTY'); + // Terminal-started with TTY expect(resolveHasTTY({ stdoutIsTTY: true, stdinIsTTY: true, startedBy: 'terminal' })).toBe(true); - expect(resolveHasTTY({ stdoutIsTTY: true, stdinIsTTY: true, startedBy: 'daemon' })).toBe(false); + // Daemon-started with TTY (e.g., tmux) - now allowed for local resume + expect(resolveHasTTY({ stdoutIsTTY: true, stdinIsTTY: true, startedBy: 'daemon' })).toBe(true); + // Missing stdout TTY expect(resolveHasTTY({ stdoutIsTTY: false, stdinIsTTY: true, startedBy: 'terminal' })).toBe(false); + // Missing stdin TTY expect(resolveHasTTY({ stdoutIsTTY: true, stdinIsTTY: false, startedBy: 'terminal' })).toBe(false); + // Daemon-started without TTY + expect(resolveHasTTY({ stdoutIsTTY: false, stdinIsTTY: false, startedBy: 'daemon' })).toBe(false); }); }); diff --git a/apps/cli/src/ui/tty/resolveHasTTY.ts b/apps/cli/src/ui/tty/resolveHasTTY.ts index a0c54d54e..a130cd1be 100644 --- a/apps/cli/src/ui/tty/resolveHasTTY.ts +++ b/apps/cli/src/ui/tty/resolveHasTTY.ts @@ -3,6 +3,8 @@ export function resolveHasTTY(params: { stdinIsTTY: unknown; startedBy?: 'daemon' | 'terminal'; }): boolean { - return Boolean(params.stdoutIsTTY) && Boolean(params.stdinIsTTY) && params.startedBy !== 'daemon'; + // Allow TUI whenever we have a real TTY (stdin + stdout), including daemon-spawned + // tmux sessions. This lets users resume locally after starting from phone/web. + return Boolean(params.stdoutIsTTY) && Boolean(params.stdinIsTTY); } diff --git a/eas.json b/eas.json new file mode 100644 index 000000000..eba8a89ca --- /dev/null +++ b/eas.json @@ -0,0 +1,21 @@ +{ + "cli": { + "version": ">= 18.0.1", + "appVersionSource": "remote" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal" + }, + "preview": { + "distribution": "internal" + }, + "production": { + "autoIncrement": true + } + }, + "submit": { + "production": {} + } +} From dba16ff29bed003b6debc27c74f8bbc521988111 Mon Sep 17 00:00:00 2001 From: Casey Key Date: Sun, 15 Mar 2026 17:33:22 -0700 Subject: [PATCH 4/6] fix(cli): build protocol before agents in shared deps --- .../scripts/__tests__/buildSharedDeps.test.ts | 19 ++++++++++++- apps/cli/scripts/buildSharedDeps.mjs | 27 +++++++++++++------ apps/cli/src/api/types.ts | 4 +-- apps/cli/src/daemon/startDaemon.ts | 2 ++ 4 files changed, 41 insertions(+), 11 deletions(-) diff --git a/apps/cli/scripts/__tests__/buildSharedDeps.test.ts b/apps/cli/scripts/__tests__/buildSharedDeps.test.ts index 73834945c..ee847f426 100644 --- a/apps/cli/scripts/__tests__/buildSharedDeps.test.ts +++ b/apps/cli/scripts/__tests__/buildSharedDeps.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { sep } from 'node:path'; -import { resolveTscBin, runTsc, syncBundledWorkspaceDist } from '../buildSharedDeps.mjs'; +import { buildSharedDeps, resolveTscBin, runTsc, syncBundledWorkspaceDist } from '../buildSharedDeps.mjs'; describe('buildSharedDeps', () => { it('surfaces which tsconfig failed when compilation throws', () => { @@ -108,4 +108,21 @@ describe('buildSharedDeps', () => { expect(parsed.exports?.['./installables']).toBeTruthy(); expect(parsed.private).toBe(true); }); + + it('builds protocol before agents so agents do not consume stale protocol declarations', () => { + const runTsc = vi.fn(() => undefined); + + buildSharedDeps({ + repoRoot: '/repo', + runTsc, + existsSync: () => true, + syncBundledWorkspaceDist: vi.fn(() => undefined), + }); + + expect(runTsc.mock.calls.map((args) => args[0])).toEqual([ + '/repo/packages/protocol/tsconfig.json', + '/repo/packages/agents/tsconfig.json', + '/repo/packages/cli-common/tsconfig.json', + ]); + }); }); diff --git a/apps/cli/scripts/buildSharedDeps.mjs b/apps/cli/scripts/buildSharedDeps.mjs index 97f5c95a5..9d0bf4fbb 100644 --- a/apps/cli/scripts/buildSharedDeps.mjs +++ b/apps/cli/scripts/buildSharedDeps.mjs @@ -133,19 +133,30 @@ function sanitizeBundledWorkspacePackageJson(raw) { }; } -export function main() { - runTsc(resolve(repoRoot, 'packages', 'agents', 'tsconfig.json')); - runTsc(resolve(repoRoot, 'packages', 'cli-common', 'tsconfig.json')); - runTsc(resolve(repoRoot, 'packages', 'protocol', 'tsconfig.json')); - - const protocolDist = resolve(repoRoot, 'packages', 'protocol', 'dist', 'index.js'); - if (!existsSync(protocolDist)) { +export function buildSharedDeps(opts = {}) { + const repoRootArg = opts.repoRoot; + const resolvedRepoRoot = typeof repoRootArg === 'string' && repoRootArg.trim() ? repoRootArg : repoRoot; + const runTscImpl = opts.runTsc ?? runTsc; + const syncBundledWorkspaceDistImpl = opts.syncBundledWorkspaceDist ?? syncBundledWorkspaceDist; + const existsSyncImpl = opts.existsSync ?? existsSync; + + // Protocol must build before agents because agents consumes protocol's published types. + runTscImpl(resolve(resolvedRepoRoot, 'packages', 'protocol', 'tsconfig.json')); + runTscImpl(resolve(resolvedRepoRoot, 'packages', 'agents', 'tsconfig.json')); + runTscImpl(resolve(resolvedRepoRoot, 'packages', 'cli-common', 'tsconfig.json')); + + const protocolDist = resolve(resolvedRepoRoot, 'packages', 'protocol', 'dist', 'index.js'); + if (!existsSyncImpl(protocolDist)) { throw new Error(`Expected @happier-dev/protocol build output missing: ${protocolDist}`); } // If the CLI currently has bundled workspace deps under apps/cli/node_modules, // keep their dist outputs in sync so local builds/tests do not consume stale artifacts. - syncBundledWorkspaceDist({ repoRoot }); + syncBundledWorkspaceDistImpl({ repoRoot: resolvedRepoRoot }); +} + +export function main() { + buildSharedDeps(); } const invokedAsMain = (() => { diff --git a/apps/cli/src/api/types.ts b/apps/cli/src/api/types.ts index 3bf608b34..89500c216 100644 --- a/apps/cli/src/api/types.ts +++ b/apps/cli/src/api/types.ts @@ -133,9 +133,9 @@ export interface ClientToServerEvents { 'ping': (callback: () => void) => void [SOCKET_RPC_EVENTS.REGISTER]: (data: { method: string }) => void [SOCKET_RPC_EVENTS.UNREGISTER]: (data: { method: string }) => void - [SOCKET_RPC_EVENTS.CALL]: (data: { method: string, params: string }, callback: (response: { + [SOCKET_RPC_EVENTS.CALL]: (data: { method: string, params: unknown }, callback: (response: { ok: boolean - result?: string + result?: unknown error?: string }) => void) => void 'usage-report': (data: { diff --git a/apps/cli/src/daemon/startDaemon.ts b/apps/cli/src/daemon/startDaemon.ts index e0c846cc8..7f7bf58e6 100644 --- a/apps/cli/src/daemon/startDaemon.ts +++ b/apps/cli/src/daemon/startDaemon.ts @@ -420,6 +420,8 @@ export async function startDaemon(): Promise { const attach = await createSessionAttachFile({ happySessionId: normalizedExistingSessionId, payload: { + v: 2, + encryptionMode: 'e2ee', encryptionKeyBase64: normalizedSessionEncryptionKeyBase64, encryptionVariant: 'dataKey', }, From 2df46f09d602a5371fea611d242010ac45ad1156 Mon Sep 17 00:00:00 2001 From: Casey Key Date: Sat, 21 Mar 2026 19:00:02 -0700 Subject: [PATCH 5/6] fix(cli): harden tmux session creation retries --- .../src/integrations/tmux/TmuxUtilities.ts | 48 ++++--- .../tmux/tmux.spawnAndEnv.test.ts | 118 ++++++++++++++++++ 2 files changed, 151 insertions(+), 15 deletions(-) diff --git a/apps/cli/src/integrations/tmux/TmuxUtilities.ts b/apps/cli/src/integrations/tmux/TmuxUtilities.ts index 8373a864c..71430408b 100644 --- a/apps/cli/src/integrations/tmux/TmuxUtilities.ts +++ b/apps/cli/src/integrations/tmux/TmuxUtilities.ts @@ -281,19 +281,25 @@ export class TmuxUtilities { /** * Ensure session exists, create if needed - * @returns true if session already existed, false if it was created + * @returns true if the session already existed, false if this call created it */ async ensureSessionExists(sessionName?: string): Promise { const targetSession = sessionName || this.sessionName; - // Check if session exists if (await this.sessionExists(targetSession)) { return true; } - // Create session if it doesn't exist const createResult = await this.executeTmuxCommand(['new-session', '-d', '-s', targetSession]); - return createResult !== null && createResult.returncode === 0; + if (createResult && createResult.returncode === 0) { + return false; + } + + if (await this.sessionExists(targetSession)) { + return true; + } + + throw new Error(`Failed to create tmux session: ${createResult?.stderr ?? 'unknown error'}`); } /** @@ -477,11 +483,9 @@ export class TmuxUtilities { const windowName = options.windowName || `happy-${Date.now()}`; - // Check if session already exists to avoid creating an extra empty window - const sessionAlreadyExisted = await this.sessionExists(sessionName); - // Ensure session exists - await this.ensureSessionExists(sessionName); + const sessionAlreadyExisted = await this.ensureSessionExists(sessionName); + const baseWindowIndex = await this.getBaseWindowIndex(sessionName); // Build command to execute in the new window const fullCommand = buildPosixShellCommand(args); @@ -541,7 +545,7 @@ export class TmuxUtilities { // On retry, query for an available index and explicitly target it. let currentArgs = createWindowArgs; if (attempt > 1) { - const availableIndex = await this.findAvailableWindowIndex(sessionName); + const availableIndex = await this.findAvailableWindowIndex(sessionName, baseWindowIndex); logger.debug(`[TMUX] Retrying with explicit window index ${availableIndex}`); // Rebuild args with explicit target: session:index const explicitTarget = `${sessionName}:${availableIndex}`; @@ -580,11 +584,11 @@ export class TmuxUtilities { logger.debug(`[TMUX] Spawned command in tmux session ${sessionName}, window ${windowName}, PID ${panePid}`); - // If session was just created, kill the empty window 0 that new-session created + // If this call created the session, remove the bootstrap window created by new-session. if (!sessionAlreadyExisted) { - const killResult = await this.executeTmuxCommand(['kill-window', '-t', `${sessionName}:0`]); + const killResult = await this.executeTmuxCommand(['kill-window', '-t', `${sessionName}:${baseWindowIndex}`]); if (killResult && killResult.returncode === 0) { - logger.debug(`[TMUX] Killed empty window 0 in newly created session ${sessionName}`); + logger.debug(`[TMUX] Killed bootstrap window ${baseWindowIndex} in newly created session ${sessionName}`); } } @@ -717,19 +721,33 @@ export class TmuxUtilities { return indices; } + /** + * Read tmux's configured base window index for the target session. + * Falls back to 0 when tmux does not report a valid value. + */ + async getBaseWindowIndex(sessionName?: string): Promise { + const targetSession = sessionName || this.sessionName; + const result = await this.executeTmuxCommand(['show-options', '-t', targetSession, '-gqv', 'base-index']); + const parsed = Number.parseInt(result?.stdout.trim() ?? '', 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; + } + /** * Find an available window index for the session. * Starts from base-index (default 0) and finds the first gap or next available. */ - async findAvailableWindowIndex(sessionName?: string): Promise { + async findAvailableWindowIndex(sessionName?: string, baseWindowIndex?: number): Promise { + const firstWindowIndex = typeof baseWindowIndex === 'number' + ? baseWindowIndex + : await this.getBaseWindowIndex(sessionName); const usedIndices = await this.getWindowIndices(sessionName); if (usedIndices.size === 0) { - return 0; + return firstWindowIndex; } // Find the first gap, or use max + 1 - let candidate = 0; + let candidate = firstWindowIndex; while (usedIndices.has(candidate)) { candidate += 1; } diff --git a/apps/cli/src/integrations/tmux/tmux.spawnAndEnv.test.ts b/apps/cli/src/integrations/tmux/tmux.spawnAndEnv.test.ts index 0db59000c..6c1ec1c49 100644 --- a/apps/cli/src/integrations/tmux/tmux.spawnAndEnv.test.ts +++ b/apps/cli/src/integrations/tmux/tmux.spawnAndEnv.test.ts @@ -273,6 +273,104 @@ describe('TmuxUtilities.spawnInTmux', () => { expect(tmux.targetArguments[1]).toBe('my-session:3'); }); + it('does not kill the bootstrap window when another process created the session first', async () => { + class ConcurrentCreatorTmuxUtilities extends TmuxUtilities { + public readonly killWindowTargets: string[] = []; + private hasSessionCalls = 0; + + override async executeTmuxCommand(cmd: string[]): Promise { + if (cmd[0] === 'has-session') { + this.hasSessionCalls += 1; + return { + returncode: this.hasSessionCalls === 1 ? 1 : 0, + stdout: '', + stderr: '', + command: cmd, + }; + } + + if (cmd[0] === 'new-window') { + return { returncode: 0, stdout: '4242\n', stderr: '', command: cmd }; + } + + if (cmd[0] === 'new-session') { + return { returncode: 1, stdout: '', stderr: 'duplicate session', command: cmd }; + } + + if (cmd[0] === 'kill-window') { + const targetIndex = cmd.indexOf('-t'); + if (targetIndex >= 0 && targetIndex + 1 < cmd.length) { + this.killWindowTargets.push(cmd[targetIndex + 1]!); + } + } + + return { returncode: 0, stdout: '', stderr: '', command: cmd }; + } + } + + const tmux = new ConcurrentCreatorTmuxUtilities(); + const result = await tmux.spawnInTmux(['echo', 'hello'], { sessionName: 'my-session', windowName: 'my-window' }, {}); + + expect(result.success).toBe(true); + expect(tmux.killWindowTargets).toEqual([]); + }); + + it('uses tmux base-index for retry targets and bootstrap cleanup', async () => { + class BaseIndexOneTmuxUtilities extends TmuxUtilities { + public readonly killWindowTargets: string[] = []; + public readonly newWindowTargets: string[] = []; + private newWindowAttempts = 0; + + override async executeTmuxCommand(cmd: string[]): Promise { + if (cmd[0] === 'has-session') { + return { returncode: 1, stdout: '', stderr: '', command: cmd }; + } + + if (cmd[0] === 'new-session') { + return { returncode: 0, stdout: '', stderr: '', command: cmd }; + } + + if (cmd[0] === 'show-options' && cmd.includes('base-index')) { + return { returncode: 0, stdout: '1\n', stderr: '', command: cmd }; + } + + if (cmd[0] === 'list-windows' && cmd.includes('#{window_index}')) { + return { returncode: 0, stdout: '1\n2\n3\n', stderr: '', command: cmd }; + } + + if (cmd[0] === 'new-window') { + this.newWindowAttempts += 1; + const targetIndex = cmd.indexOf('-t'); + if (targetIndex >= 0 && targetIndex + 1 < cmd.length) { + this.newWindowTargets.push(cmd[targetIndex + 1]!); + } + + if (this.newWindowAttempts === 1) { + return { returncode: 1, stdout: '', stderr: 'create window failed: index 1 in use.', command: cmd }; + } + + return { returncode: 0, stdout: '4242\n', stderr: '', command: cmd }; + } + + if (cmd[0] === 'kill-window') { + const targetIndex = cmd.indexOf('-t'); + if (targetIndex >= 0 && targetIndex + 1 < cmd.length) { + this.killWindowTargets.push(cmd[targetIndex + 1]!); + } + } + + return { returncode: 0, stdout: '', stderr: '', command: cmd }; + } + } + + const tmux = new BaseIndexOneTmuxUtilities(); + const result = await tmux.spawnInTmux(['echo', 'hello'], { sessionName: 'my-session', windowName: 'my-window' }, {}); + + expect(result.success).toBe(true); + expect(tmux.newWindowTargets).toEqual(['my-session', 'my-session:4']); + expect(tmux.killWindowTargets).toEqual(['my-session:1']); + }); + it('returns an error when tmux new-window output is not a numeric pane PID', async () => { class InvalidPidTmuxUtilities extends FakeTmuxUtilities { override async executeTmuxCommand(cmd: string[], session?: string): Promise { @@ -359,4 +457,24 @@ describe('TmuxUtilities.findAvailableWindowIndex', () => { const index = await tmux.findAvailableWindowIndex('my-session'); expect(index).toBe(0); }); + + it('starts from tmux base-index when configured above zero', async () => { + class BaseIndexOneNoWindowsTmux extends TmuxUtilities { + public override async executeTmuxCommand(cmd: string[]): Promise { + if (cmd[0] === 'show-options' && cmd.includes('base-index')) { + return { returncode: 0, stdout: '1\n', stderr: '', command: cmd }; + } + + if (cmd[0] === 'list-windows') { + return { returncode: 0, stdout: '1\n2\n3\n', stderr: '', command: cmd }; + } + + return { returncode: 0, stdout: '', stderr: '', command: cmd }; + } + } + + const tmux = new BaseIndexOneNoWindowsTmux(); + const index = await tmux.findAvailableWindowIndex('my-session'); + expect(index).toBe(4); + }); }); From 81f6403daefd3700c1907dba4717df462aaddefe Mon Sep 17 00:00:00 2001 From: Casey Key Date: Sun, 22 Mar 2026 17:22:09 -0700 Subject: [PATCH 6/6] fix(cli): keep daemon tty guard but allow tmux resumes --- .../src/daemon/platform/tmux/spawnConfig.ts | 6 +++++- apps/cli/src/ui/tty/resolveHasTTY.test.ts | 14 +++++++++++--- apps/cli/src/ui/tty/resolveHasTTY.ts | 19 +++++++++++++++---- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/daemon/platform/tmux/spawnConfig.ts b/apps/cli/src/daemon/platform/tmux/spawnConfig.ts index c00dcd49f..6a6688620 100644 --- a/apps/cli/src/daemon/platform/tmux/spawnConfig.ts +++ b/apps/cli/src/daemon/platform/tmux/spawnConfig.ts @@ -52,7 +52,11 @@ export function buildTmuxSpawnConfig(params: { const launchSpec = buildHappyCliSubprocessLaunchSpec(args); const commandTokens = [launchSpec.filePath, ...launchSpec.args]; - const tmuxEnv = buildTmuxWindowEnv(process.env, { ...params.extraEnv, ...(launchSpec.env ?? {}) }); + const tmuxEnv = buildTmuxWindowEnv(process.env, { + ...params.extraEnv, + ...(launchSpec.env ?? {}), + HAPPIER_CLI_ALLOW_DAEMON_TTY_IN_TMUX: '1', + }); const tmuxCommandEnv: Record = { ...(params.tmuxCommandEnv ?? {}) }; const tmuxTmpDir = tmuxCommandEnv.TMUX_TMPDIR; diff --git a/apps/cli/src/ui/tty/resolveHasTTY.test.ts b/apps/cli/src/ui/tty/resolveHasTTY.test.ts index 0c6722a7c..46acfd119 100644 --- a/apps/cli/src/ui/tty/resolveHasTTY.test.ts +++ b/apps/cli/src/ui/tty/resolveHasTTY.test.ts @@ -6,8 +6,17 @@ describe('resolveHasTTY', () => { // Terminal-started with TTY expect(resolveHasTTY({ stdoutIsTTY: true, stdinIsTTY: true, startedBy: 'terminal' })).toBe(true); - // Daemon-started with TTY (e.g., tmux) - now allowed for local resume - expect(resolveHasTTY({ stdoutIsTTY: true, stdinIsTTY: true, startedBy: 'daemon' })).toBe(true); + // Daemon-started with TTY but without tmux -> still disallowed + expect(resolveHasTTY({ stdoutIsTTY: true, stdinIsTTY: true, startedBy: 'daemon' })).toBe(false); + // Daemon-started with tmux + feature flag should be allowed + process.env.TMUX = 'tmux-1234/default,1000,0'; + process.env.HAPPIER_CLI_ALLOW_DAEMON_TTY_IN_TMUX = '1'; + try { + expect(resolveHasTTY({ stdoutIsTTY: true, stdinIsTTY: true, startedBy: 'daemon' })).toBe(true); + } finally { + delete process.env.TMUX; + delete process.env.HAPPIER_CLI_ALLOW_DAEMON_TTY_IN_TMUX; + } // Missing stdout TTY expect(resolveHasTTY({ stdoutIsTTY: false, stdinIsTTY: true, startedBy: 'terminal' })).toBe(false); // Missing stdin TTY @@ -16,4 +25,3 @@ describe('resolveHasTTY', () => { expect(resolveHasTTY({ stdoutIsTTY: false, stdinIsTTY: false, startedBy: 'daemon' })).toBe(false); }); }); - diff --git a/apps/cli/src/ui/tty/resolveHasTTY.ts b/apps/cli/src/ui/tty/resolveHasTTY.ts index a130cd1be..7e5656e28 100644 --- a/apps/cli/src/ui/tty/resolveHasTTY.ts +++ b/apps/cli/src/ui/tty/resolveHasTTY.ts @@ -3,8 +3,19 @@ export function resolveHasTTY(params: { stdinIsTTY: unknown; startedBy?: 'daemon' | 'terminal'; }): boolean { - // Allow TUI whenever we have a real TTY (stdin + stdout), including daemon-spawned - // tmux sessions. This lets users resume locally after starting from phone/web. - return Boolean(params.stdoutIsTTY) && Boolean(params.stdinIsTTY); -} + const hasBothTtys = Boolean(params.stdoutIsTTY) && Boolean(params.stdinIsTTY); + if (!hasBothTtys) { + return false; + } + + if (params.startedBy !== 'daemon') { + return true; + } + const allowDaemonTmux = process.env.HAPPIER_CLI_ALLOW_DAEMON_TTY_IN_TMUX === '1'; + if (!allowDaemonTmux) { + return false; + } + + return Boolean(process.env.TMUX) || Boolean(process.env.TMUX_PANE); +}