diff --git a/windows/StickShift.App/App.xaml.cs b/windows/StickShift.App/App.xaml.cs index da07423..ce7014c 100644 --- a/windows/StickShift.App/App.xaml.cs +++ b/windows/StickShift.App/App.xaml.cs @@ -7,11 +7,14 @@ public partial class App : Application protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); - // Optional explicit target: --target "". If omitted, the window - // auto-detects the first Claude/Codex session it can read. - string? target = null; - for (int i = 0; i < e.Args.Length - 1; i++) - if (e.Args[i] == "--target") target = e.Args[i + 1]; - new GearboxWindow(target).Show(); + // Optional explicit target: --target "". If omitted (or given with no + // value), the window auto-detects the first Claude/Codex session it can read. (The old loop + // bound `< Length - 1` silently dropped --target when it was the final argument.) + string? explicitTarget = null; + string[] arguments = e.Args; + for (int index = 0; index < arguments.Length; index++) + if (arguments[index] == "--target" && index + 1 < arguments.Length) + explicitTarget = arguments[index + 1]; + new GearboxWindow(explicitTarget).Show(); } } diff --git a/windows/StickShift.App/GearboxWindow.xaml.cs b/windows/StickShift.App/GearboxWindow.xaml.cs index 4bccaa7..35c93f0 100644 --- a/windows/StickShift.App/GearboxWindow.xaml.cs +++ b/windows/StickShift.App/GearboxWindow.xaml.cs @@ -66,9 +66,14 @@ function add(){ public GearboxWindow(string? target) { _explicitTarget = target; + // In the gearbox, PULLING A GEAR *is* the confirmation (macOS app default, AppDelegate.m:47-51): + // auto-confirm the "Switch model?" dialog so a pull completes instead of stalling at DIALOG_OPEN + // mid-conversation. The CLI keeps the conservative Ask default; this app-level choice matches Mark's. + _cfg.DialogPolicy = DialogPolicy.Confirm; + _cfg.AutoAnswerEnabled = true; InitializeComponent(); Loaded += OnLoaded; - KeyDown += (_, e) => { if (e.Key == Key.Escape) Close(); }; + KeyDown += (_, keyEvent) => { if (keyEvent.Key == Key.Escape) Close(); }; } async void OnLoaded(object sender, RoutedEventArgs e) @@ -114,37 +119,48 @@ void PushLive(string? expectModel = null) { System.Threading.Tasks.Task.Run(() => { - string agent = "claude", model = "", token = "", effort = ""; + // Empty agent (not "claude") when nothing qualifies, so the HTML shows "focus a terminal + // pane" with the live chip off — matching the macOS shell, instead of a fake green chip. + string agentKind = "", modelDisplay = "", modelToken = "", effortText = ""; try { - string target = ResolveTarget(); - if (!string.IsNullOrEmpty(target)) + string targetTitle = ResolveTarget(); + if (!string.IsNullOrEmpty(targetTitle)) { - var h = WindowFocus.FindWindowByTitle(target); - if (h != IntPtr.Zero) + var targetWindow = WindowFocus.FindWindowByTitle(targetTitle); + if (targetWindow != IntPtr.Zero) { - var st = WindowFocus.ReadActiveAgentPane(h); - var deadline = DateTime.UtcNow.AddSeconds(3); + var paneState = WindowFocus.ReadActiveAgentPane(targetWindow); + var readDeadline = DateTime.UtcNow.AddSeconds(3); while (expectModel != null - && !Switch.DialogTargetMatchesExpected(st.ModelText, expectModel) - && DateTime.UtcNow < deadline) + && !Switch.DialogTargetMatchesExpected(paneState.ModelText, expectModel) + && DateTime.UtcNow < readDeadline) { Thread.Sleep(200); - st = WindowFocus.ReadActiveAgentPane(h); + paneState = WindowFocus.ReadActiveAgentPane(targetWindow); + } + agentKind = paneState.Agent switch + { + AgentKind.Claude => "claude", + AgentKind.Codex => "codex", + _ => "" + }; + if (paneState.Agent != AgentKind.Unknown) + { + modelDisplay = paneState.ModelText ?? ""; + effortText = paneState.EffortText ?? ""; + modelToken = TokenForModel(modelDisplay); } - if (st.Agent == AgentKind.Codex) agent = "codex"; - model = st.ModelText ?? ""; - effort = st.EffortText ?? ""; - token = TokenForModel(model); } } } catch { } Dispatcher.Invoke(async () => { - string js = $"window.setLive({{agent:'{Esc(agent)}',model:'{Esc(model)}'," + - $"token:'{Esc(token)}',effort:'{Esc(effort)}'}})"; - await web.CoreWebView2.ExecuteScriptAsync(js); + string setLiveScript = + $"window.setLive({{agent:{ToJsLiteral(agentKind)},model:{ToJsLiteral(modelDisplay)}," + + $"token:{ToJsLiteral(modelToken)},effort:{ToJsLiteral(effortText)}}})"; + await web.CoreWebView2.ExecuteScriptAsync(setLiveScript); }); }); } @@ -176,80 +192,98 @@ void OnWebMessage(object? sender, CoreWebView2WebMessageReceivedEventArgs e) { try { - using var doc = JsonDocument.Parse(e.WebMessageAsJson); - var name = doc.RootElement.GetProperty("name").GetString(); + using var message = JsonDocument.Parse(e.WebMessageAsJson); + var messageName = message.RootElement.GetProperty("name").GetString(); - if (name == "drag") + if (messageName == "drag") { - var h = new WindowInteropHelper(this).EnsureHandle(); + var windowHandle = new WindowInteropHelper(this).EnsureHandle(); ReleaseCapture(); - SendMessage(h, WM_NCLBUTTONDOWN, (IntPtr)HTCAPTION, IntPtr.Zero); + SendMessage(windowHandle, WM_NCLBUTTONDOWN, (IntPtr)HTCAPTION, IntPtr.Zero); return; } - if (name == "pin") + if (messageName == "pin") { - bool pinned = doc.RootElement.TryGetProperty("body", out var pb) - && pb.TryGetProperty("pinned", out var pv) && pv.GetBoolean(); + bool pinned = message.RootElement.TryGetProperty("body", out var pinBody) + && pinBody.TryGetProperty("pinned", out var pinnedValue) && pinnedValue.GetBoolean(); Topmost = pinned; return; } - if (name == "resize") + if (messageName == "resize") { // Mark's collapse (–) hides the stage and asks the host to shrink to the header bar; // honor it so the button doesn't leave dead space below a full-height frame. Expanded // height mirrors the macOS shell's 760x440 content rect (AppDelegate.m). - bool compact = doc.RootElement.TryGetProperty("body", out var rb) - && rb.TryGetProperty("compact", out var cv) && cv.GetBoolean(); - Height = compact ? 64 : 440; + bool collapsed = message.RootElement.TryGetProperty("body", out var resizeBody) + && resizeBody.TryGetProperty("compact", out var compactValue) && compactValue.GetBoolean(); + Height = collapsed ? 64 : 440; return; } - if (name == "policy") + if (messageName == "policy") { - var pol = doc.RootElement.GetProperty("body").TryGetProperty("policy", out var pp) ? pp.GetString() : null; - _cfg.DialogPolicy = pol switch { "ask" => DialogPolicy.Ask, "cancel" => DialogPolicy.Cancel, _ => DialogPolicy.Confirm }; - _cfg.AutoAnswerEnabled = pol is "confirm" or "cancel"; + var policyName = message.RootElement.GetProperty("body").TryGetProperty("policy", out var policyValue) ? policyValue.GetString() : null; + _cfg.DialogPolicy = policyName switch { "ask" => DialogPolicy.Ask, "cancel" => DialogPolicy.Cancel, _ => DialogPolicy.Confirm }; + _cfg.AutoAnswerEnabled = policyName is "confirm" or "cancel"; return; } - if (name != "shift") return; + if (messageName != "shift") return; - var body = doc.RootElement.GetProperty("body"); - string gate = body.TryGetProperty("gate", out var gp) ? gp.GetString() ?? "" : ""; + var body = message.RootElement.GetProperty("body"); + string gate = body.TryGetProperty("gate", out var gateProperty) ? gateProperty.GetString() ?? "" : ""; // The UI's tuple IS the shift (Mark's runModelToken:effort: semantics): fire() sends // {model, effort, gate} on every action — gear pulls AND throttle-only moves (where // model = the live token and gate may be ''). gate is only echoed back for the glow. - string uiModel = body.TryGetProperty("model", out var mp) ? mp.GetString() ?? "" : ""; - string uiEffort = body.TryGetProperty("effort", out var ep) ? ep.GetString() ?? "" : ""; - GearTuple? tuple = uiModel.Length > 0 - ? new GearTuple(uiModel, uiEffort.Length > 0 ? uiEffort : null) + string uiModelToken = body.TryGetProperty("model", out var modelProperty) ? modelProperty.GetString() ?? "" : ""; + string uiEffortToken = body.TryGetProperty("effort", out var effortProperty) ? effortProperty.GetString() ?? "" : ""; + GearTuple? uiTuple = uiModelToken.Length > 0 + ? new GearTuple(uiModelToken, uiEffortToken.Length > 0 ? uiEffortToken : null) : null; // no model token (shouldn't happen — fire() guards) -> fall back to the gear table System.Threading.Tasks.Task.Run(() => { - string target = ResolveTarget(); - ShiftOutcome r = string.IsNullOrEmpty(target) - ? new ShiftOutcome { Reason = "NOT_TERMINAL", Detail = "no Claude/Codex session found" } - : SwitchDriver.Shift(target, gate, _cfg, commit: true, log: null, tupleOverride: tuple); + ShiftOutcome outcome; + try + { + string targetTitle = ResolveTarget(); + outcome = string.IsNullOrEmpty(targetTitle) + ? new ShiftOutcome { Reason = "NOT_TERMINAL", Detail = "no Claude/Codex session found" } + : SwitchDriver.Shift(targetTitle, gate, _cfg, commit: true, log: null, tupleOverride: uiTuple); + } + catch (Exception readException) + { + // A UIA fault must STILL resolve the HTML's pending state, or the gearbox freezes + // on a phantom gear (one-outcome-per-shift contract). Deliver an error outcome. + outcome = new ShiftOutcome { Reason = "READ_ERROR", Stage = "INJECT", Detail = readException.Message }; + } - bool ok = r.Reason is "CHANGED" or "ALREADY_SET"; - bool warn = r.Reason is "BUSY" or "DRAFT_PRESENT" or "DIALOG_OPEN" or "NOT_TERMINAL" or "NO_AGENT"; + // Buckets mirror the macOS shell (AppDelegate.m): landed = changed/already; warnable = + // every refusal PLUS UNKNOWN_FINAL_STATE (committed but unverified) — so a shift that + // likely took doesn't render as a red error and snap the knob back to the old model. + bool landed = outcome.Reason is "CHANGED" or "ALREADY_SET"; + bool warnable = outcome.Reason is "BUSY" or "DRAFT_PRESENT" or "DIALOG_OPEN" or "NOT_TERMINAL" + or "NO_AGENT" or "NO_FOCUS" or "LOCKED" or "UNCHANGED" or "UNKNOWN_FINAL_STATE"; Dispatcher.Invoke(async () => { - string js = $"window.outcome({{reason:'{Esc(r.Reason)}',detail:'{Esc(r.ToString())}'," + - $"ok:{(ok ? "true" : "false")},warn:{(warn ? "true" : "false")}," + - $"activeGate:'{(ok ? Esc(gate) : "")}'}})"; - await web.CoreWebView2.ExecuteScriptAsync(js); + string outcomeScript = + $"window.outcome({{reason:{ToJsLiteral(outcome.Reason)},detail:{ToJsLiteral(outcome.Detail)}," + + $"ok:{(landed ? "true" : "false")},warn:{(warnable ? "true" : "false")}," + + $"activeGate:{ToJsLiteral(landed ? gate : "")}}})"; + await web.CoreWebView2.ExecuteScriptAsync(outcomeScript); }); - // Reflect the session's real post-shift state back into the console — waiting for the - // footer to agree with the model this shift just verified (see PushLive). - if (ok) + // Reconcile the console/knob with the session's REAL post-shift state on ANY commit + // (landed, or UNKNOWN_FINAL_STATE which likely landed) — never leave the UI on the old model. + if (landed || outcome.Committed) { - string? tok = tuple?.Model ?? _cfg.TupleForGear(gate, AgentKind.Claude)?.Model; - PushLive(tok != null ? ShiftProtocol.ClaudeDisplayForToken(tok) : null); + string? modelToken = uiTuple?.Model ?? _cfg.TupleForGear(gate, AgentKind.Claude)?.Model; + PushLive(landed && modelToken != null ? ShiftProtocol.ClaudeDisplayForToken(modelToken) : null); } }); } catch { } } - static string Esc(string? s) => (s ?? "").Replace("\\", "\\\\").Replace("'", "\\'"); + // Outbound JS-literal encoder: JSON-serialize so every value reaching ExecuteScriptAsync is a + // properly-escaped double-quoted literal — closes newline / U+2028 / U+2029 / quote breakouts + // that a hand-rolled '..'-escaper misses when pane-derived text (model, detail) is untrusted. + static string ToJsLiteral(string? value) => System.Text.Json.JsonSerializer.Serialize(value ?? ""); } diff --git a/windows/StickShift.Cli/Program.cs b/windows/StickShift.Cli/Program.cs index dda6b85..4ad556b 100644 --- a/windows/StickShift.Cli/Program.cs +++ b/windows/StickShift.Cli/Program.cs @@ -72,26 +72,52 @@ static int Main(string[] args) if (st.Agent == AgentKind.Unknown) { Console.WriteLine("NO_AGENT — active pane is not a recognized agent"); return 1; } if (st.Busy) { Console.WriteLine("BUSY — refusing to press Escape while the agent is running (it would interrupt)"); return 1; } if (st.InputEmpty) { Console.WriteLine("ALREADY_EMPTY — composer has no draft"); return 0; } - if (!WindowFocus.Focus(hwnd)) { Console.WriteLine("NO_FOCUS — could not bring the target to foreground"); return 1; } - // Backspace the draft away, one key per character (+ margin — extra backspaces on an - // empty composer are harmless). NOT Escape: typing "/…" opens the slash-autocomplete - // popup, and Esc closes that popup instead of clearing the text (observed live). - int draftLen = 0; - foreach (var raw in (st.PaneText ?? "").Replace("\r", "").Split('\n')) + // Serialize with the SAME interprocess lock the shift uses, so a clear-draft can't + // interleave keystrokes with a concurrent shift (CLI + GUI, or two pulls) into the same + // pane. Fail-closed if we can't take it quickly. (Matches SwitchDriver's StickShiftInjectionLock.) + using var injectionGate = new Mutex(false, "StickShiftInjectionLock"); + bool lockAcquired; + try { lockAcquired = injectionGate.WaitOne(TimeSpan.FromMilliseconds(600)); } + catch (AbandonedMutexException) { lockAcquired = true; } // a prior holder died mid-inject; inherit + if (!lockAcquired) { Console.WriteLine("LOCKED — another shift/clear is in progress — try again"); return 1; } + try { - var ln = raw.TrimEnd(); - var lt = ln.TrimStart(); - if (lt == ">" || lt.StartsWith("> ")) draftLen = Math.Max(draftLen, lt.Length - 1); + if (!WindowFocus.Focus(hwnd)) { Console.WriteLine("NO_FOCUS — could not bring the target to foreground"); return 1; } + // Backspace the draft away, one key per character (+ margin — extra backspaces on an + // empty composer are harmless). NOT Escape: typing "/…" opens the slash-autocomplete + // popup, and Esc closes that popup instead of clearing the text (observed live). + int draftLen = 0; + foreach (var raw in (st.PaneText ?? "").Replace("\r", "").Split('\n')) + { + var ln = raw.TrimEnd(); + var lt = ln.TrimStart(); + if (lt == ">" || lt.StartsWith("> ")) draftLen = Math.Max(draftLen, lt.Length - 1); + } + int presses = Math.Min(draftLen + 8, 300); + Thread.Sleep(150); + // Re-assert foreground BEFORE every Backspace and ABORT if it isn't the target. A + // focus drop mid-loop must NOT blind-fire destructive Backspaces into whatever window + // now owns focus — the same fail-closed rule SwitchDriver applies to every keystroke, + // and it matters most here because Backspace is destructive (was: Focus() called but + // its bool ignored, so up to 300 backspaces could land in the wrong window). + for (int k = 0; k < presses; k++) + { + if (!WindowFocus.Focus(hwnd)) + { + Console.WriteLine($"NO_FOCUS — target lost foreground after {k} backspaces; aborted before typing into the wrong window"); + return 1; + } + Injector.PressBackspace(); + Thread.Sleep(15); + } + Thread.Sleep(400); + var after = WindowFocus.ReadActiveAgentPane(hwnd); + Console.WriteLine(after.InputEmpty + ? $"CLEARED — composer verified empty ({presses} backspaces)" + : "STILL_PRESENT — composer not empty after backspacing"); + return after.InputEmpty ? 0 : 1; } - int presses = Math.Min(draftLen + 8, 300); - Thread.Sleep(150); - for (int k = 0; k < presses; k++) { WindowFocus.Focus(hwnd); Injector.PressBackspace(); Thread.Sleep(15); } - Thread.Sleep(400); - var after = WindowFocus.ReadActiveAgentPane(hwnd); - Console.WriteLine(after.InputEmpty - ? $"CLEARED — composer verified empty ({presses} backspaces)" - : "STILL_PRESENT — composer not empty after backspacing"); - return after.InputEmpty ? 0 : 1; + finally { injectionGate.ReleaseMutex(); } } string gear = args[0]; diff --git a/windows/StickShift.Core.Tests/Program.cs b/windows/StickShift.Core.Tests/Program.cs index 8a04418..d8d40e6 100644 --- a/windows/StickShift.Core.Tests/Program.cs +++ b/windows/StickShift.Core.Tests/Program.cs @@ -135,6 +135,10 @@ void Check(bool cond, string name) // draft variant: composer with text => NOT empty var wcDraft = Classify(WinClaudeIdle.Replace("> \n", "> build the next thing\n")); Check(!wcDraft.InputEmpty, "win claude draft: inputEmpty=NO (DRAFT_PRESENT)"); +// draft that BEGINS WITH a placeholder prefix must still be a draft, not empty: the old +// StartsWith match classified "Ask Claude about X" as an empty composer and typed over it. +var wcPlaceholderDraft = Classify(WinClaudeIdle.Replace("> \n", "> Ask Claude about the focus race\n")); +Check(!wcPlaceholderDraft.InputEmpty, "win claude draft starting with placeholder: inputEmpty=NO (exact-match, not StartsWith)"); // === Config (gear table + injection-safe charset) — port of Config.m === Console.WriteLine("\n== config =="); diff --git a/windows/StickShift.Core/PaneClassifier.cs b/windows/StickShift.Core/PaneClassifier.cs index 56abacf..65cb34f 100644 --- a/windows/StickShift.Core/PaneClassifier.cs +++ b/windows/StickShift.Core/PaneClassifier.cs @@ -240,7 +240,10 @@ static bool IsInputEmpty(string[] lines, AgentKind agent) var rest = Trim(ln[markerLen..]); if (rest.Length == 0) return true; // empty composer if (rest.StartsWith("1.") || rest.StartsWith("2.")) continue; // dialog option - foreach (var ph in ClaudePlaceholders) if (rest.StartsWith(ph)) return true; + // EXACT match only (like the Codex branch): a StartsWith would classify a real + // draft that merely BEGINS with a placeholder ("Ask Claude about the race…") as + // empty and type over it. An empty composer is already caught by rest.Length==0. + foreach (var ph in ClaudePlaceholders) if (rest == ph) return true; return false; // draft present } } diff --git a/windows/StickShift.Core/Switch.cs b/windows/StickShift.Core/Switch.cs index 928ad89..b85eddf 100644 --- a/windows/StickShift.Core/Switch.cs +++ b/windows/StickShift.Core/Switch.cs @@ -84,11 +84,11 @@ public static StepDecision DecideStep(PlanStep step, PaneState p, bool autoAnswe // Success via classified status line (only when no dialog is open, so the dialog body // naming the target model can't be a false positive). if (step.ExpectModel != null && !p.SwitchDialogOpen && DialogTargetMatchesExpected(p.ModelText, step.ExpectModel)) return StepDecision.Matched; - if (step.ExpectEffort != null) - { - if (p.EffortLive && p.EffortText == step.ExpectEffort) return StepDecision.Matched; // the LIVE ◉/○ chip - if (bottom.Contains("Set effort level to " + step.ExpectEffort)) return StepDecision.Matched; - } + // Effort match is the LIVE chip only. The "Set effort level to " confirmation is verified + // in the driver against a pre-injection baseline (fresh occurrence), NOT here — a bare + // bottom-Contains would false-pass on a stale confirmation from a prior run in scrollback. + if (step.ExpectEffort != null && p.EffortLive && p.EffortText == step.ExpectEffort) + return StepDecision.Matched; if (step.ExpectModel == null && step.ExpectEffort == null && step.Text != null && BottomLines(txt, 16).Contains(step.Text)) return StepDecision.Matched; // The switch-confirm dialog. Only answer OUR dialog: the extracted target must equal diff --git a/windows/StickShift.Os/SwitchDriver.cs b/windows/StickShift.Os/SwitchDriver.cs index 0e36731..344ea89 100644 --- a/windows/StickShift.Os/SwitchDriver.cs +++ b/windows/StickShift.Os/SwitchDriver.cs @@ -48,10 +48,31 @@ public static ShiftOutcome Shift(string targetTitle, string gear, Config cfg, bo return new() { Reason = "OK", Stage = "DRY_RUN", PlanSummary = plan!.Summary, Detail = $"would apply — {plan.Summary}" }; } - // COMMIT: focus the target window, then operate on the FOCUSED (active) pane so the pane we - // read is provably the pane keystrokes reach. + // COMMIT: serialize with an interprocess lock so a second client (CLI + GUI, or two quick + // pulls) can't interleave keystrokes into the same pane. Session-local mutex; fail-closed + // if we can't take it quickly. (WINDOWS.md step: "Named mutex (CreateMutex).") + using var injectionGate = new Mutex(false, "StickShiftInjectionLock"); + bool lockAcquired; + try { lockAcquired = injectionGate.WaitOne(TimeSpan.FromMilliseconds(600)); } + catch (AbandonedMutexException) { lockAcquired = true; } // a prior holder died mid-shift; we inherit + if (!lockAcquired) + return new() { Reason = "LOCKED", Stage = "PRECHECK", Detail = "another shift is in progress — try again" }; + try { return CommitShift(target, gear, cfg, log, tupleOverride); } + finally { injectionGate.ReleaseMutex(); } + } + + static ShiftOutcome NoFocus(string detail) => new() { Reason = "NO_FOCUS", Stage = "INJECT", Detail = detail }; + + // The read -> precheck -> inject -> verify pipeline, run under the injection lock. Every keystroke + // site re-asserts foreground AND checks it landed: if the target isn't foreground (user alt-tabbed + // mid-shift, or SetForegroundWindow was denied), abort with NO_FOCUS BEFORE the keystroke rather + // than blind-type Return/Escape/digits into whatever window now owns focus. + static ShiftOutcome CommitShift(IntPtr target, string gear, Config cfg, Action? log, GearTuple? tupleOverride) + { + // focus the target window, then operate on the FOCUSED (active) pane so the pane we read + // is provably the pane keystrokes reach. if (!WindowFocus.Focus(target)) - return new() { Reason = "NO_FOCUS", Stage = "INJECT", Detail = "could not bring the target window to foreground" }; + return NoFocus("could not bring the target window to foreground"); Thread.Sleep(150); log?.Invoke($"[dbg] after Focus(target): foreground='{WindowFocus.ForegroundWindowTitle()}'"); @@ -65,8 +86,10 @@ public static ShiftOutcome Shift(string targetTitle, string gear, Config cfg, bo if (activeRefusal != null) return activeRefusal; // ALREADY_SET / BUSY / DRAFT etc. on the active pane SwitchPlan plan2 = activePlan!; - // Baseline for the robust model-confirmation verify — set just before a /model is injected. - int modelConfirmBaseline = -1; + // Baselines for the robust confirmation verify — set just before a /model or /effort is + // injected, so the WATCH matches only a FRESH confirmation line (a stale one sits in the + // baseline and can't false-pass). + int modelConfirmBaseline = -1, effortConfirmBaseline = -1; foreach (PlanStep step in plan2.Steps) { @@ -87,9 +110,12 @@ public static ShiftOutcome Shift(string targetTitle, string gear, Config cfg, bo // stale confirmation from a prior run sits in the baseline, so it can't false-pass. if (typed.StartsWith("/model") && plan2.ExpectedModelDisplay != null) modelConfirmBaseline = Switch.OccurrencesOf("Set model to " + plan2.ExpectedModelDisplay, paneBeforeType); + if (typed.StartsWith("/effort") && plan2.ExpectedEffort != null) + effortConfirmBaseline = Switch.OccurrencesOf("Set effort level to " + plan2.ExpectedEffort, paneBeforeType); // The UIA read above transiently drops foreground to an empty window; re-assert it // with NO UIA between here and SendInput so the keystrokes reliably reach the pane. - WindowFocus.Focus(target); + // Verify the re-assert: never type into a window that isn't provably foreground. + if (!WindowFocus.Focus(target)) return NoFocus($"target lost foreground before typing '{typed}'"); log?.Invoke($"[dbg] typing '{typed}' (before-count={before}); foreground='{WindowFocus.ForegroundWindowTitle()}'"); Injector.TypeText(typed); bool landed = false; @@ -103,11 +129,11 @@ public static ShiftOutcome Shift(string targetTitle, string gear, Config cfg, bo return new() { Reason = "INJECT_DROPPED", Stage = "INJECT", Detail = $"typed '{typed}' but it never appeared in the focused pane — keystrokes did not reach it" }; break; } - case StepKind.Return: WindowFocus.Focus(target); Injector.PressReturn(); Thread.Sleep(120); break; - case StepKind.Escape: WindowFocus.Focus(target); Injector.PressEscape(); Thread.Sleep(120); break; - case StepKind.Down: WindowFocus.Focus(target); Injector.PressDown(); Thread.Sleep(120); break; - case StepKind.Up: WindowFocus.Focus(target); Injector.PressUp(); Thread.Sleep(120); break; - case StepKind.Digit: WindowFocus.Focus(target); Injector.PressDigit(step.Digit); Thread.Sleep(120); break; + case StepKind.Return: if (!WindowFocus.Focus(target)) return NoFocus("target lost foreground before Return"); Injector.PressReturn(); Thread.Sleep(120); break; + case StepKind.Escape: if (!WindowFocus.Focus(target)) return NoFocus("target lost foreground before Escape"); Injector.PressEscape(); Thread.Sleep(120); break; + case StepKind.Down: if (!WindowFocus.Focus(target)) return NoFocus("target lost foreground before Down"); Injector.PressDown(); Thread.Sleep(120); break; + case StepKind.Up: if (!WindowFocus.Focus(target)) return NoFocus("target lost foreground before Up"); Injector.PressUp(); Thread.Sleep(120); break; + case StepKind.Digit: if (!WindowFocus.Focus(target)) return NoFocus("target lost foreground before Digit"); Injector.PressDigit(step.Digit); Thread.Sleep(120); break; case StepKind.CodexSelect: { PaneState picker = WindowFocus.ReadActiveAgentPane(target); @@ -116,12 +142,13 @@ public static ShiftOutcome Shift(string targetTitle, string gear, Config cfg, bo return new() { Reason = "BAD_CONFIG", Stage = "INJECT", Detail = $"'{step.Text}' not offered in the codex picker" }; if (row > 9) return new() { Reason = "BAD_CONFIG", Stage = "INJECT", Detail = $"picker row {row} exceeds single-digit selection" }; - WindowFocus.Focus(target); Injector.PressDigit(row); Thread.Sleep(150); + if (!WindowFocus.Focus(target)) return NoFocus("target lost foreground before picker select"); + Injector.PressDigit(row); Thread.Sleep(150); break; } case StepKind.WaitState: { - ShiftOutcome? wr = AwaitStep(step, target, cfg, plan2.ExpectedModelDisplay, modelConfirmBaseline); + ShiftOutcome? wr = AwaitStep(step, target, cfg, plan2.ExpectedModelDisplay, modelConfirmBaseline, effortConfirmBaseline); if (wr != null) return wr; break; } @@ -171,18 +198,22 @@ public static ShiftOutcome Shift(string targetTitle, string gear, Config cfg, bo // Poll a WaitState step using the PURE Switch.DecideStep against the FOCUSED (active) pane. // null => Matched (success); a non-null ShiftOutcome => terminal (error / dialog / cancel / timeout). - static ShiftOutcome? AwaitStep(PlanStep step, IntPtr target, Config cfg, string? modelDisp = null, int modelConfirmBaseline = -1) + static ShiftOutcome? AwaitStep(PlanStep step, IntPtr target, Config cfg, string? modelDisp = null, int modelConfirmBaseline = -1, int effortConfirmBaseline = -1) { DateTime deadline = DateTime.UtcNow.AddSeconds(5); bool confirmed = false; while (DateTime.UtcNow < deadline) { PaneState p = WindowFocus.ReadActiveAgentPane(target); - // Robust model verify: a FRESH "Set model to " confirmation (count risen past the - // pre-injection baseline) proves the switch landed even when the status-footer read misses. + // Robust verify: a FRESH confirmation line (count risen past the pre-injection baseline) + // proves the switch landed even when the status-footer / effort-chip read misses — and, + // unlike a bare bottom-Contains, a stale confirmation from a prior run can't false-pass. if (step.ExpectModel != null && modelConfirmBaseline >= 0 && !string.IsNullOrEmpty(modelDisp) && Switch.OccurrencesOf("Set model to " + modelDisp, p.PaneText) > modelConfirmBaseline) return null; + if (step.ExpectEffort != null && effortConfirmBaseline >= 0 + && Switch.OccurrencesOf("Set effort level to " + step.ExpectEffort, p.PaneText) > effortConfirmBaseline) + return null; StepDecision d = Switch.DecideStep(step, p, cfg.AutoAnswerEnabled, cfg.DialogPolicy, confirmed); switch (d) { @@ -195,10 +226,12 @@ public static ShiftOutcome Shift(string targetTitle, string gear, Config cfg, bo return new() { Reason = "DIALOG_OPEN", Stage = "WATCH", Detail = "Claude asked to confirm the switch; confirm in the terminal or enable auto-confirm" }; case StepDecision.Cancel: - WindowFocus.Focus(target); Injector.PressDigit(2); + if (!WindowFocus.Focus(target)) return NoFocus("target lost foreground before dialog cancel"); + Injector.PressDigit(2); return new() { Reason = "UNCHANGED", Stage = "WATCH", Detail = "cancelled per policy" }; case StepDecision.Confirm: - WindowFocus.Focus(target); Injector.PressReturn(); confirmed = true; Thread.Sleep(300); continue; + if (!WindowFocus.Focus(target)) return NoFocus("target lost foreground before dialog confirm"); + Injector.PressReturn(); confirmed = true; Thread.Sleep(300); continue; case StepDecision.Wait: default: break; } diff --git a/windows/StickShift.Os/UiaPaneReader.cs b/windows/StickShift.Os/UiaPaneReader.cs index 3ff18dc..593b288 100644 --- a/windows/StickShift.Os/UiaPaneReader.cs +++ b/windows/StickShift.Os/UiaPaneReader.cs @@ -104,7 +104,10 @@ public static PaneState ReadFocusedPaneState() if ((bool)el.GetCurrentPropertyValue(AutomationElement.IsTextPatternAvailableProperty)) { var tp = (TextPattern)el.GetCurrentPattern(TextPattern.Pattern); - return tp.DocumentRange.GetText(100_000); + // GetText(maxLen) truncates from the START, losing the live screen on long scrollback; + // read the full range and keep the tail (the bottom is where every check anchors). + var fullText = tp.DocumentRange.GetText(-1) ?? ""; + return fullText.Length > 200_000 ? fullText[^200_000..] : fullText; } } catch { /* fall through */ } diff --git a/windows/StickShift.Os/WindowFocus.cs b/windows/StickShift.Os/WindowFocus.cs index 38dd429..aca4054 100644 --- a/windows/StickShift.Os/WindowFocus.cs +++ b/windows/StickShift.Os/WindowFocus.cs @@ -82,7 +82,7 @@ public static PaneState ReadPaneState(IntPtr window) foreach (AutomationElement pane in el.FindAll(TreeScope.Descendants, cond)) { string text; - try { text = ((TextPattern)pane.GetCurrentPattern(TextPattern.Pattern)).DocumentRange.GetText(100_000) ?? ""; } + try { var fullText = ((TextPattern)pane.GetCurrentPattern(TextPattern.Pattern)).DocumentRange.GetText(-1) ?? ""; text = fullText.Length > 200_000 ? fullText[^200_000..] : fullText; } catch { continue; } if (string.IsNullOrEmpty(text)) continue; var candidate = new PaneState { HasFocusedWindow = true, WindowTitle = title, PaneText = text }; @@ -111,7 +111,7 @@ public static (PaneState pane, bool focused) FocusActiveAgentPane(IntPtr window) foreach (AutomationElement pane in el.FindAll(TreeScope.Descendants, cond)) { string text; - try { text = ((TextPattern)pane.GetCurrentPattern(TextPattern.Pattern)).DocumentRange.GetText(100_000) ?? ""; } + try { var fullText = ((TextPattern)pane.GetCurrentPattern(TextPattern.Pattern)).DocumentRange.GetText(-1) ?? ""; text = fullText.Length > 200_000 ? fullText[^200_000..] : fullText; } catch { continue; } if (string.IsNullOrEmpty(text)) continue; var st = new PaneState { HasFocusedWindow = true, WindowTitle = empty.WindowTitle, PaneText = text }; @@ -146,11 +146,11 @@ public static PaneState ReadActiveAgentPane(IntPtr window) { var el = AutomationElement.FromHandle(window); var cond = new PropertyCondition(AutomationElement.IsTextPatternAvailableProperty, true); - PaneState? anyAgent = null, firstReadable = null; + PaneState? onScreenAny = null, anyAgent = null, firstReadable = null; foreach (AutomationElement pane in el.FindAll(TreeScope.Descendants, cond)) { string text; - try { text = ((TextPattern)pane.GetCurrentPattern(TextPattern.Pattern)).DocumentRange.GetText(100_000) ?? ""; } + try { var fullText = ((TextPattern)pane.GetCurrentPattern(TextPattern.Pattern)).DocumentRange.GetText(-1) ?? ""; text = fullText.Length > 200_000 ? fullText[^200_000..] : fullText; } catch { continue; } if (string.IsNullOrEmpty(text)) continue; var st = new PaneState { HasFocusedWindow = true, WindowTitle = title, PaneText = text }; @@ -158,10 +158,15 @@ public static PaneState ReadActiveAgentPane(IntPtr window) bool onScreen = false; try { onScreen = !(bool)pane.GetCurrentPropertyValue(AutomationElement.IsOffscreenProperty); } catch { } if (st.Agent != AgentKind.Unknown && onScreen) return st; // the ACTIVE agent pane + if (onScreen) onScreenAny ??= st; // on-screen, not (yet) an agent if (st.Agent != AgentKind.Unknown) anyAgent ??= st; firstReadable ??= st; } - return anyAgent ?? firstReadable ?? empty; + // Prefer an ON-SCREEN pane (even a non-agent one -> caller refuses NO_AGENT) over an + // OFF-SCREEN agent: SendInput reaches only the active tab, so acting on an off-screen + // agent pane would type into the wrong place. The off-screen agent is a read-only last + // resort (dry-run/diagnostics), never the on-screen commit target. + return onScreenAny ?? anyAgent ?? firstReadable ?? empty; } catch { return empty; } }