diff --git a/cmd/launch/codex_app.go b/cmd/launch/codex_app.go index 454fe90d..0ec3a45a 100644 --- a/cmd/launch/codex_app.go +++ b/cmd/launch/codex_app.go @@ -39,11 +39,16 @@ var ( codexAppOpenPath = defaultCodexAppOpenAppPath codexAppOpenStart = defaultCodexAppOpenStartAppID codexAppQuitApp = defaultCodexAppQuitApp + codexAppForceQuit = defaultCodexAppForceQuitApp + codexAppHasWindow = defaultCodexAppHasOpenWindow codexAppIsRunning = defaultCodexAppIsRunning codexAppRunPath = defaultCodexAppRunningAppPath codexAppStartID = defaultCodexAppStartAppID codexAppCanOpenID = defaultCodexAppCanOpenBundleID codexAppSleep = time.Sleep + + codexAppExitTimeout = 5 * time.Second + codexAppForceExitTimeout = 5 * time.Second ) // CodexApp configures the desktop Codex app with one launch-selected default @@ -367,7 +372,7 @@ type codexAppModelMetadata struct { func codexAppDefaultModelMetadata() codexAppModelMetadata { return codexAppModelMetadata{ - contextWindow: 272_000, + contextWindow: 128_000, inputModalities: []string{"text"}, } } @@ -567,8 +572,21 @@ func codexAppLaunchOrRestart(prompt string) error { if err := codexAppQuitApp(); err != nil { return fmt.Errorf("quit Codex: %w", err) } - if err := waitForCodexAppExit(30 * time.Second); err != nil { - return err + gracefulErr := waitForCodexAppGracefulExit(codexAppExitTimeout) + if gracefulErr != nil && !codexAppForceQuitSupported() { + return gracefulErr + } + if codexAppForceQuitSupported() && codexAppIsRunning() { + if forceErr := codexAppForceQuit(); forceErr != nil { + return fmt.Errorf("force stop Codex: %w", forceErr) + } + if err := waitForCodexAppExit(codexAppForceExitTimeout); err != nil { + return err + } + } else if gracefulErr != nil { + if codexAppIsRunning() { + return gracefulErr + } } if restartAppID != "" { return codexAppOpenStart(restartAppID) @@ -579,10 +597,29 @@ func codexAppLaunchOrRestart(prompt string) error { return codexAppOpenApp() } +func codexAppForceQuitSupported() bool { + return codexAppGOOS == "darwin" || codexAppGOOS == "windows" +} + +func waitForCodexAppGracefulExit(timeout time.Duration) error { + return waitForCodexAppCondition(timeout, func() bool { + if codexAppGOOS == "windows" { + return !codexAppHasWindow() + } + return !codexAppIsRunning() + }) +} + func waitForCodexAppExit(timeout time.Duration) error { + return waitForCodexAppCondition(timeout, func() bool { + return !codexAppIsRunning() + }) +} + +func waitForCodexAppCondition(timeout time.Duration, done func() bool) error { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { - if !codexAppIsRunning() { + if done() { return nil } codexAppSleep(200 * time.Millisecond) @@ -650,6 +687,46 @@ func defaultCodexAppQuitApp() error { return scriptErr } +func defaultCodexAppForceQuitApp() error { + if !codexAppForceQuitSupported() { + return nil + } + pids := codexAppMatchingProcessIDs() + if len(pids) == 0 { + return nil + } + pidArgs := make([]string, 0, len(pids)) + for _, pid := range pids { + pidArgs = append(pidArgs, strconv.Itoa(pid)) + } + switch codexAppGOOS { + case "windows": + script := "Stop-Process -Id " + strings.Join(pidArgs, ",") + " -Force -ErrorAction SilentlyContinue" + return runCodexAppForceQuitCommand(exec.Command("powershell.exe", "-NoProfile", "-Command", script)) + case "darwin": + return runCodexAppForceQuitCommand(exec.Command("kill", append([]string{"-TERM"}, pidArgs...)...)) + default: + return nil + } +} + +func runCodexAppForceQuitCommand(cmd *exec.Cmd) error { + err := cmd.Run() + if err != nil && !codexAppIsRunning() { + return nil + } + return err +} + +func defaultCodexAppHasOpenWindow() bool { + if codexAppGOOS != "windows" { + return codexAppIsRunning() + } + script := `(Get-Process Codex -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1).Id` + out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", script).Output() + return err == nil && strings.TrimSpace(string(out)) != "" +} + func defaultCodexAppIsRunning() bool { switch codexAppGOOS { case "windows": diff --git a/cmd/launch/codex_app_test.go b/cmd/launch/codex_app_test.go index d1f1f85f..b2bf242f 100644 --- a/cmd/launch/codex_app_test.go +++ b/cmd/launch/codex_app_test.go @@ -30,10 +30,15 @@ func withCodexAppProcessHooks(t *testing.T, isRunning func() bool, quit func() e oldOpen := codexAppOpenApp oldOpenPath := codexAppOpenPath oldOpenStart := codexAppOpenStart + oldForceQuit := codexAppForceQuit + oldHasWindow := codexAppHasWindow oldRunPath := codexAppRunPath oldStartID := codexAppStartID oldCanOpenID := codexAppCanOpenID + oldExitTimeout := codexAppExitTimeout + oldForceExitTimeout := codexAppForceExitTimeout codexAppIsRunning = isRunning + codexAppHasWindow = isRunning codexAppQuitApp = quit codexAppOpenApp = open t.Cleanup(func() { @@ -42,9 +47,13 @@ func withCodexAppProcessHooks(t *testing.T, isRunning func() bool, quit func() e codexAppOpenApp = oldOpen codexAppOpenPath = oldOpenPath codexAppOpenStart = oldOpenStart + codexAppForceQuit = oldForceQuit + codexAppHasWindow = oldHasWindow codexAppRunPath = oldRunPath codexAppStartID = oldStartID codexAppCanOpenID = oldCanOpenID + codexAppExitTimeout = oldExitTimeout + codexAppForceExitTimeout = oldForceExitTimeout }) } @@ -585,7 +594,7 @@ func TestCodexAppConfigurePopulatesCatalogFromTagsAndShow(t *testing.T) { if !ok || len(levels) != 0 { t.Fatalf("supported_reasoning_levels for %q = %v, want empty list", slug, model["supported_reasoning_levels"]) } - wantContext := float64(272000) + wantContext := float64(128000) wantModalities := []string{"text"} wantShowCalls := 0 if slug == "gemma4" { @@ -1165,6 +1174,64 @@ func TestCodexAppRunWaitsForGracefulExitBeforeReopening(t *testing.T) { } } +func TestCodexAppRunForceStopsMacAfterGracefulTimeout(t *testing.T) { + withCodexAppPlatform(t, "darwin") + restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) + defer restoreConfirm() + + running := true + calls := make([]string, 0) + withCodexAppProcessHooks(t, + func() bool { return running }, + func() error { + calls = append(calls, "quit") + return nil + }, + func() error { + calls = append(calls, "open") + return nil + }, + ) + codexAppExitTimeout = 0 + codexAppForceQuit = func() error { + calls = append(calls, "force") + running = false + return nil + } + + if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil { + t.Fatalf("Run returned error: %v", err) + } + want := []string{"quit", "force", "open"} + if strings.Join(calls, ",") != strings.Join(want, ",") { + t.Fatalf("calls = %v, want %v", calls, want) + } +} + +func TestCodexAppRunReturnsMacForceStopError(t *testing.T) { + withCodexAppPlatform(t, "darwin") + restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) + defer restoreConfirm() + + withCodexAppProcessHooks(t, + func() bool { return true }, + func() error { return nil }, + func() error { + t.Fatal("app should not reopen when force stop fails") + return nil + }, + ) + codexAppExitTimeout = 0 + codexAppForceQuit = func() error { + return fmt.Errorf("operation not permitted") + } + + err := (&CodexApp{}).Run("qwen3.5", nil) + if err == nil || !strings.Contains(err.Error(), "force stop Codex") || !strings.Contains(err.Error(), "operation not permitted") { + t.Fatalf("Run error = %v, want force stop failure", err) + } +} + func TestCodexAppRunOpensOnWindowsWhenNotRunning(t *testing.T) { withCodexAppPlatform(t, "windows") @@ -1234,6 +1301,79 @@ func TestCodexAppRunRestartsWindowsStartAppID(t *testing.T) { } } +func TestCodexAppRunForceStopsWindowsBackgroundProcessesBeforeReopening(t *testing.T) { + withCodexAppPlatform(t, "windows") + restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) + defer restoreConfirm() + + windowOpen := true + running := true + calls := make([]string, 0) + withCodexAppProcessHooks(t, + func() bool { return running }, + func() error { + calls = append(calls, "quit") + windowOpen = false + return nil + }, + func() error { + t.Fatal("open app fallback should not be used") + return nil + }, + ) + codexAppHasWindow = func() bool { return windowOpen } + codexAppForceQuit = func() error { + calls = append(calls, "force") + running = false + return nil + } + codexAppStartID = func() string { return "OpenAI.Codex_2p2nqsd0c76g0!App" } + codexAppOpenStart = func(appID string) error { + calls = append(calls, "open:"+appID) + return nil + } + + if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil { + t.Fatalf("Run returned error: %v", err) + } + want := []string{"quit", "force", "open:OpenAI.Codex_2p2nqsd0c76g0!App"} + if strings.Join(calls, ",") != strings.Join(want, ",") { + t.Fatalf("calls = %v, want %v", calls, want) + } +} + +func TestCodexAppRunReturnsWindowsForceStopError(t *testing.T) { + withCodexAppPlatform(t, "windows") + restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) + defer restoreConfirm() + + windowOpen := true + withCodexAppProcessHooks(t, + func() bool { return true }, + func() error { + windowOpen = false + return nil + }, + func() error { + t.Fatal("open app fallback should not be used") + return nil + }, + ) + codexAppHasWindow = func() bool { return windowOpen } + codexAppForceQuit = func() error { + return fmt.Errorf("access denied") + } + codexAppOpenStart = func(string) error { + t.Fatal("app should not reopen when force stop fails") + return nil + } + + err := (&CodexApp{}).Run("qwen3.5", nil) + if err == nil || !strings.Contains(err.Error(), "force stop Codex") || !strings.Contains(err.Error(), "access denied") { + t.Fatalf("Run error = %v, want force stop failure", err) + } +} + func TestCodexAppRunRejectsExtraArgs(t *testing.T) { withCodexAppPlatform(t, "darwin") err := (&CodexApp{}).Run("qwen3.5", []string{"--foo"})