diff --git a/apps/cli/package.json b/apps/cli/package.json index 6b9c1bf22..dd33b6f93 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", diff --git a/apps/cli/scripts/__tests__/buildSharedDeps.test.ts b/apps/cli/scripts/__tests__/buildSharedDeps.test.ts index e88ed05df..57639be6d 100644 --- a/apps/cli/scripts/__tests__/buildSharedDeps.test.ts +++ b/apps/cli/scripts/__tests__/buildSharedDeps.test.ts @@ -4,6 +4,7 @@ import { join, resolve, sep } from 'node:path'; import { createTempDirSync, removeTempDirSync } from '../../src/testkit/fs/tempDir'; import { + buildSharedDeps, resolveTscBin, runTsc, syncBundledWorkspaceDist, @@ -214,6 +215,35 @@ describe('buildSharedDeps', () => { ).toBe(true); }); + it('builds protocol before agents so agents do not consume stale protocol declarations', () => { + const runTsc = vi.fn(() => undefined); + const syncBundledWorkspaceDist = vi.fn(() => undefined); + const syncBundledWorkspaceRuntimeDependencies = vi.fn(() => undefined); + const syncCliRuntimeDependencies = vi.fn(() => undefined); + + buildSharedDeps({ + repoRoot: '/repo', + runTsc, + existsSync: () => true, + syncBundledWorkspaceDist, + syncBundledWorkspaceRuntimeDependencies, + syncCliRuntimeDependencies, + }); + + const expectedTsconfigs = [ + '/repo/packages/protocol/tsconfig.json', + '/repo/packages/agents/tsconfig.json', + '/repo/packages/cli-common/tsconfig.json', + '/repo/packages/connection-supervisor/tsconfig.json', + '/repo/packages/transfers/tsconfig.json', + '/repo/packages/release-runtime/tsconfig.json', + ]; + expect(runTsc.mock.calls.map((args) => args[0])).toEqual(expectedTsconfigs); + expect(syncBundledWorkspaceDist).toHaveBeenCalledWith({ repoRoot: '/repo' }); + expect(syncBundledWorkspaceRuntimeDependencies).toHaveBeenCalledWith({ repoRoot: '/repo' }); + expect(syncCliRuntimeDependencies).toHaveBeenCalledWith({ repoRoot: '/repo' }); + }); + it('bundles tweetnacl into the CLI publish tree for packaged installs', () => { const { repoRoot, happyCliDir, cleanup } = createPackageLayoutSandbox('happy-build-shared-runtime-'); @@ -288,4 +318,26 @@ describe('buildSharedDeps', () => { removeTempDirSync(rootDir); } }); + + 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), + syncCliRuntimeDependencies: vi.fn(() => undefined), + syncBundledWorkspaceRuntimeDependencies: 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', + '/repo/packages/connection-supervisor/tsconfig.json', + '/repo/packages/transfers/tsconfig.json', + '/repo/packages/release-runtime/tsconfig.json', + ]); + }); }); diff --git a/apps/cli/scripts/buildSharedDeps.mjs b/apps/cli/scripts/buildSharedDeps.mjs index 380e6c5cb..49e707a4f 100644 --- a/apps/cli/scripts/buildSharedDeps.mjs +++ b/apps/cli/scripts/buildSharedDeps.mjs @@ -247,25 +247,43 @@ export function syncBundledWorkspaceRuntimeDependencies(opts = {}) { } } +export function buildSharedDeps(opts = {}) { + const repoRootArg = opts.repoRoot; + const resolvedRepoRoot = + typeof repoRootArg === 'string' && repoRootArg.trim() ? repoRootArg : findRepoRoot(__dirname); + const runTscImpl = opts.runTsc ?? runTsc; + const syncBundledWorkspaceDistImpl = opts.syncBundledWorkspaceDist ?? syncBundledWorkspaceDist; + const syncBundledWorkspaceRuntimeDependenciesImpl = + opts.syncBundledWorkspaceRuntimeDependencies ?? syncBundledWorkspaceRuntimeDependencies; + const syncCliRuntimeDependenciesImpl = opts.syncCliRuntimeDependencies ?? syncCliRuntimeDependencies; + const existsSyncImpl = opts.existsSync ?? existsSync; + + const tsconfigPaths = [ + resolve(resolvedRepoRoot, 'packages', 'protocol', 'tsconfig.json'), + resolve(resolvedRepoRoot, 'packages', 'agents', 'tsconfig.json'), + resolve(resolvedRepoRoot, 'packages', 'cli-common', 'tsconfig.json'), + resolve(resolvedRepoRoot, 'packages', 'connection-supervisor', 'tsconfig.json'), + resolve(resolvedRepoRoot, 'packages', 'transfers', 'tsconfig.json'), + resolve(resolvedRepoRoot, 'packages', 'release-runtime', 'tsconfig.json'), + ]; + + for (const tsconfigPath of tsconfigPaths) { + runTscImpl(tsconfigPath); + } + + const protocolDist = resolve(resolvedRepoRoot, 'packages', 'protocol', 'dist', 'index.js'); + if (!existsSyncImpl(protocolDist)) { + throw new Error(`Expected @happier-dev/protocol build output missing: ${protocolDist}`); + } + + syncBundledWorkspaceDistImpl({ repoRoot: resolvedRepoRoot }); + syncBundledWorkspaceRuntimeDependenciesImpl({ repoRoot: resolvedRepoRoot }); + syncCliRuntimeDependenciesImpl({ repoRoot: resolvedRepoRoot }); +} + export function main() { return withBuildSharedDepsLock(async () => { - runTsc(resolve(repoRoot, 'packages', 'agents', 'tsconfig.json')); - runTsc(resolve(repoRoot, 'packages', 'cli-common', 'tsconfig.json')); - runTsc(resolve(repoRoot, 'packages', 'connection-supervisor', 'tsconfig.json')); - runTsc(resolve(repoRoot, 'packages', 'protocol', 'tsconfig.json')); - runTsc(resolve(repoRoot, 'packages', 'transfers', 'tsconfig.json')); - runTsc(resolve(repoRoot, 'packages', 'release-runtime', 'tsconfig.json')); - - const protocolDist = resolve(repoRoot, 'packages', 'protocol', 'dist', 'index.js'); - if (!existsSync(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 }); - syncBundledWorkspaceRuntimeDependencies({ repoRoot }); - syncCliRuntimeDependencies({ repoRoot }); + buildSharedDeps({ repoRoot }); }); } diff --git a/apps/cli/src/agent/runtime/runStandardAcpProvider.ts b/apps/cli/src/agent/runtime/runStandardAcpProvider.ts index 33642672e..d324033c3 100644 --- a/apps/cli/src/agent/runtime/runStandardAcpProvider.ts +++ b/apps/cli/src/agent/runtime/runStandardAcpProvider.ts @@ -379,7 +379,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/api/types.ts b/apps/cli/src/api/types.ts index d68c3477a..bba80dec8 100644 --- a/apps/cli/src/api/types.ts +++ b/apps/cli/src/api/types.ts @@ -181,7 +181,11 @@ 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: SocketRpcCallPayload, callback: (response: SocketRpcCallResponse) => void) => void + [SOCKET_RPC_EVENTS.CALL]: (data: { method: string, params: unknown }, callback: (response: { + ok: boolean + result?: unknown + error?: string + }) => void) => void 'usage-report': (data: { key: string sessionId: string diff --git a/apps/cli/src/backends/claude/claudeRemoteLauncher.ts b/apps/cli/src/backends/claude/claudeRemoteLauncher.ts index 821533a95..38da79132 100644 --- a/apps/cli/src/backends/claude/claudeRemoteLauncher.ts +++ b/apps/cli/src/backends/claude/claudeRemoteLauncher.ts @@ -30,6 +30,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 { getLatestAssistantMessagePreview, getSessionNotificationTitle } from '@/agent/runtime/readyNotificationContext'; import { shouldSendReadyPushNotification } from '@/settings/notifications/notificationsPolicy'; import { dirname, join } from 'node:path'; @@ -220,6 +221,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 9990a7c3e..f34ab1f5d 100644 --- a/apps/cli/src/backends/claude/runClaude.ts +++ b/apps/cli/src/backends/claude/runClaude.ts @@ -760,35 +760,38 @@ export async function runClaude(credentials: Credentials, options: StartOptions }), }); - registerKillSessionHandler(session.rpcHandlerManager, async () => { + registerKillSessionHandler(session.rpcHandlerManager, session.sessionId, async () => { terminationHandlers.requestTermination({ kind: 'killSession' }); await terminationHandlers.whenTerminated; }); // Create claude loop - const resolvedMcp = await resolveRunnerMcpServers({ - session, - credentials, - accountSettings, - machineId, - directory: workingDirectory, - sessionMetadata: session.getMetadataSnapshot(), - }); - const exitCode = await loop({ - path: workingDirectory, - model: options.model, - permissionMode: options.permissionMode, - permissionModeUpdatedAt: options.permissionModeUpdatedAt, - startingMode: options.startingMode, - claudeCodeExperimentalAgentTeamsEnabled: currentClaudeRemoteMetaState.claudeCodeExperimentalAgentTeamsEnabled, - startedBy: options.startedBy, - messageQueue, - session, - pushSender: api.push(), - accountSettings, - precomputedMcpBridge: { mcpServers: resolvedMcp.mcpServers, stop: resolvedMcp.happierMcpServer.stop }, - onModeChange: (newMode) => { - session.sendSessionEvent({ type: 'switch', mode: newMode }); + const resolvedMcp = await resolveRunnerMcpServers({ + session, + credentials, + accountSettings, + machineId, + directory: workingDirectory, + sessionMetadata: session.getMetadataSnapshot(), + }); + const exitCode = await loop({ + path: workingDirectory, + model: options.model, + permissionMode: options.permissionMode, + permissionModeUpdatedAt: options.permissionModeUpdatedAt, + startingMode: options.startingMode, + claudeCodeExperimentalAgentTeamsEnabled: currentClaudeRemoteMetaState.claudeCodeExperimentalAgentTeamsEnabled, + startedBy: options.startedBy, + messageQueue, + session, + pushSender: api.push(), + accountSettings, + precomputedMcpBridge: { + mcpServers: resolvedMcp.mcpServers, + stop: resolvedMcp.happierMcpServer.stop, + }, + onModeChange: (newMode) => { + session.sendSessionEvent({ type: 'switch', mode: newMode }); updateAgentStateBestEffort( session, (currentState) => ({ @@ -812,11 +815,10 @@ export async function runClaude(credentials: Credentials, options: StartOptions rebuildLocalPermissionBridge(); } }, - claudeArgs: options.claudeArgs, - hookSettingsPath, - jsRuntime: options.jsRuntime, - defaultSystemPromptText, - }); + claudeArgs: options.claudeArgs, + hookSettingsPath, + jsRuntime: options.jsRuntime, + }); terminationHandlers.dispose(); @@ -1414,7 +1416,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 259ee1cec..6d6480066 100644 --- a/apps/cli/src/backends/codex/runCodex.ts +++ b/apps/cli/src/backends/codex/runCodex.ts @@ -894,7 +894,7 @@ export async function runCodex(opts: { return await runtime.rollbackConversation(parsed.data); }); - registerKillSessionHandler(session.rpcHandlerManager, handleKillSession); + registerKillSessionHandler(session.rpcHandlerManager, session.sessionId, handleKillSession); // // Initialize Ink UI diff --git a/apps/cli/src/backends/gemini/acp/backend.ts b/apps/cli/src/backends/gemini/acp/backend.ts index a240a216a..126323e17 100644 --- a/apps/cli/src/backends/gemini/acp/backend.ts +++ b/apps/cli/src/backends/gemini/acp/backend.ts @@ -202,16 +202,16 @@ export function createGeminiBackend(options: GeminiBackendOptions): GeminiBacken permissionHandler: options.permissionHandler, transportHandler: geminiTransport, authMethodId, - // Check if prompt instructs the agent to change title (for auto-approval of change_title tool) - hasChangeTitleInstruction: (prompt: string) => { - const lower = prompt.toLowerCase(); - return ( - CHANGE_TITLE_TOOL_NAME_ALIASES.some((alias) => lower.includes(alias)) || + // Check if prompt instructs the agent to change title (for auto-approval of change_title tool) + hasChangeTitleInstruction: (prompt: string) => { + const lower = prompt.toLowerCase(); + return ( + CHANGE_TITLE_TOOL_NAME_ALIASES.some((alias) => lower.includes(alias)) || lower.includes('change title') || - lower.includes('set title') - ); - }, - }; + lower.includes('set title') + ); + }, + }; // Determine model source for logging const modelSource = getGeminiModelSource(options.model, localConfig, mergedSourceEnv); diff --git a/apps/cli/src/backends/gemini/acp/transport.ts b/apps/cli/src/backends/gemini/acp/transport.ts index 52f452f56..a7419584d 100644 --- a/apps/cli/src/backends/gemini/acp/transport.ts +++ b/apps/cli/src/backends/gemini/acp/transport.ts @@ -302,7 +302,7 @@ export class GeminiTransport implements TransportHandler { const syntheticTitleOnlyInput = isSyntheticTitleOnlyInferenceInput(input); const opaqueToolPrefix = syntheticTitleOnlyInput ? extractOpaqueToolNamePrefix(toolCallId) : null; - // 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 9b6806b47..9ab756db1 100644 --- a/apps/cli/src/backends/gemini/runGemini.ts +++ b/apps/cli/src/backends/gemini/runGemini.ts @@ -547,7 +547,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/daemon/platform/tmux/spawnConfig.ts b/apps/cli/src/daemon/platform/tmux/spawnConfig.ts index 2ebee5c68..cdf91daed 100644 --- a/apps/cli/src/daemon/platform/tmux/spawnConfig.ts +++ b/apps/cli/src/daemon/platform/tmux/spawnConfig.ts @@ -54,7 +54,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/integrations/tmux/TmuxUtilities.ts b/apps/cli/src/integrations/tmux/TmuxUtilities.ts index a56c24396..4f5bc44f4 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, @@ -279,21 +280,36 @@ 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 the session already existed, false if this call created it */ 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; } - // 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'}`); } /** @@ -478,7 +494,8 @@ export class TmuxUtilities { const windowName = options.windowName || `happy-${Date.now()}`; // 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); @@ -527,75 +544,40 @@ 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); - const withExplicitTargetWindowIndex = (args: string[], target: string): string[] => { - const copy = [...args]; - const tIndex = copy.indexOf('-t'); - if (tIndex >= 0 && copy[tIndex + 1]) { - copy[tIndex + 1] = target; - return copy; - } - copy.push('-t', target); - return copy; - }; - - const resolveNextWindowIndex = async (targetSessionName: string): Promise => { - const listResult = await this.executeTmuxCommand(['list-windows', '-t', targetSessionName, '-F', '#{window_index}']); - if (!listResult || listResult.returncode !== 0) return null; - - const indices = listResult.stdout - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => Number.parseInt(line, 10)) - .filter((n) => Number.isFinite(n) && n >= 0); - const maxIndex = indices.length > 0 ? Math.max(...indices) : 0; - return maxIndex + 1; - }; - - const parseWindowIndexConflict = (stderr: string | undefined): number | null => { - const match = /index\s+(\d+)\s+in\s+use/i.exec(stderr ?? ''); - if (!match) return null; - const n = Number.parseInt(match[1] ?? '', 10); - return Number.isFinite(n) && n >= 0 ? n : null; - }; - let createResult: TmuxCommandResult | null = null; - let createWindowArgsForAttempt = createWindowArgs; + let lastArgs = createWindowArgs; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - createResult = await this.executeTmuxCommand(createWindowArgsForAttempt); + let currentArgs = createWindowArgs; + if (attempt > 1) { + const availableIndex = await this.findAvailableWindowIndex(sessionName, baseWindowIndex); + logger.debug(`[TMUX] Retrying with explicit window index ${availableIndex}`); + const explicitTarget = `${sessionName}:${availableIndex}`; + currentArgs = this.buildArgsWithExplicitTarget(createWindowArgs, explicitTarget); + } + + lastArgs = currentArgs; + createResult = await this.executeTmuxCommand(currentArgs); if (createResult && createResult.returncode === 0) break; const stderr = createResult?.stderr; const shouldRetry = attempt < maxAttempts && isTmuxWindowIndexConflict(stderr); if (!shouldRetry) break; - // In high-concurrency starts, tmux may keep retrying the same conflicting index. - // Allocate an explicit next index as a deterministic fallback. - const conflictingIndex = parseWindowIndexConflict(stderr); - const nextIndexFromList = await resolveNextWindowIndex(sessionName); - const conflictPlusOne = conflictingIndex !== null ? conflictingIndex + 1 : null; - const nextIndex = - nextIndexFromList !== null && conflictPlusOne !== null - ? Math.max(nextIndexFromList, conflictPlusOne) - : (nextIndexFromList ?? conflictPlusOne); - if (nextIndex !== null) { - createWindowArgsForAttempt = withExplicitTargetWindowIndex(createWindowArgs, `${sessionName}:${nextIndex}`); - } - - 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)); } } if (!createResult || createResult.returncode !== 0) { - const tIndex = createWindowArgsForAttempt.indexOf('-t'); - const target = tIndex >= 0 ? createWindowArgsForAttempt[tIndex + 1] : sessionName; + const tIndex = lastArgs.indexOf('-t'); + const target = tIndex >= 0 ? lastArgs[tIndex + 1] : sessionName; throw new Error(`Failed to create tmux window (target=${target}): ${createResult?.stderr}`); } @@ -613,6 +595,14 @@ export class TmuxUtilities { logger.debug(`[TMUX] Spawned command in tmux session ${sessionName}, window ${windowName}, PID ${panePid}`); + // 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}:${baseWindowIndex}`]); + if (killResult && killResult.returncode === 0) { + logger.debug(`[TMUX] Killed bootstrap window ${baseWindowIndex} in newly created session ${sessionName}`); + } + } + // Return tmux session info and PID const sessionIdentifier: TmuxSessionIdentifier = { session: sessionName, @@ -635,6 +625,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 */ @@ -691,4 +707,61 @@ 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; + } + + /** + * 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, baseWindowIndex?: number): Promise { + const firstWindowIndex = typeof baseWindowIndex === 'number' + ? baseWindowIndex + : await this.getBaseWindowIndex(sessionName); + const usedIndices = await this.getWindowIndices(sessionName); + + if (usedIndices.size === 0) { + return firstWindowIndex; + } + + // Find the first gap, or use max + 1 + let candidate = firstWindowIndex; + 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 19c52c70f..6947d4fc0 100644 --- a/apps/cli/src/integrations/tmux/env.ts +++ b/apps/cli/src/integrations/tmux/env.ts @@ -21,6 +21,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/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..46acfd119 100644 --- a/apps/cli/src/ui/tty/resolveHasTTY.test.ts +++ b/apps/cli/src/ui/tty/resolveHasTTY.test.ts @@ -1,13 +1,27 @@ 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); + // 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 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..7e5656e28 100644 --- a/apps/cli/src/ui/tty/resolveHasTTY.ts +++ b/apps/cli/src/ui/tty/resolveHasTTY.ts @@ -3,6 +3,19 @@ export function resolveHasTTY(params: { stdinIsTTY: unknown; startedBy?: 'daemon' | 'terminal'; }): boolean { - return Boolean(params.stdoutIsTTY) && Boolean(params.stdinIsTTY) && params.startedBy !== 'daemon'; -} + 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); +} 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": {} + } +}