dsh-codex-style-computer-use 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -489,8 +489,22 @@ namespace DshComputerUse.Worker
489
489
  // ---- focus / integrity probing (2026-09-13 "typing never lands" investigation) ----
490
490
  [DllImport("user32.dll")]
491
491
  public static extern IntPtr GetParent(IntPtr hWnd);
492
+ // GW_OWNER=4: a modal dialog's owner is the window that opened it (SameInputFamily).
492
493
  [DllImport("user32.dll")]
493
- public static extern bool IsWindow(IntPtr hWnd);
494
+ public static extern IntPtr GetWindow(IntPtr hWnd, uint cmd);
495
+ // ---- caret evidence: where WOULD the next character go? ----
496
+ // GUITHREADINFO reports the caret for the FOREGROUND THREAD without AttachThreadInput, and
497
+ // works in apps where UIA has no TextPattern (Word's document, many Win32 editors).
498
+ [StructLayout(LayoutKind.Sequential)]
499
+ public struct GUITHREADINFO
500
+ {
501
+ public int cbSize;
502
+ public int flags;
503
+ public IntPtr hwndActive, hwndFocus, hwndCapture, hwndMenuOwner, hwndMoveSize, hwndCaret;
504
+ public RECT rcCaret;
505
+ }
506
+ [DllImport("user32.dll")] public static extern bool GetGUIThreadInfo(uint idThread, ref GUITHREADINFO info);
507
+ [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr hWnd);
494
508
  [DllImport("user32.dll")]
495
509
  public static extern IntPtr WindowFromPoint(POINT p);
496
510
  [DllImport("user32.dll")]
@@ -512,6 +526,24 @@ namespace DshComputerUse.Worker
512
526
  public static extern IntPtr OpenProcess(uint access, bool inherit, uint pid);
513
527
  [DllImport("kernel32.dll", SetLastError = true)]
514
528
  public static extern bool CloseHandle(IntPtr h);
529
+ // ---- process ancestry: is a covered window the target app's OWN embedded renderer? ----
530
+ // A WeChat article panel (WeChatAppEx) or an Electron utility process is a legitimate
531
+ // descendant of the app the caller aimed at; an unrelated window on top is not.
532
+ public const uint TH32CS_SNAPPROCESS = 0x00000002;
533
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
534
+ public struct PROCESSENTRY32W
535
+ {
536
+ public uint dwSize, cntUsage, th32ProcessID;
537
+ public IntPtr th32DefaultHeapID;
538
+ public uint th32ModuleID, cntThreads, th32ParentProcessID, pcPriClassBase, dwFlags;
539
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szExeFile;
540
+ }
541
+ [DllImport("kernel32.dll", SetLastError = true)]
542
+ public static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint pid);
543
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
544
+ public static extern bool Process32FirstW(IntPtr snapshot, ref PROCESSENTRY32W entry);
545
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
546
+ public static extern bool Process32NextW(IntPtr snapshot, ref PROCESSENTRY32W entry);
515
547
  // ---- host guard: which executable is the process that owns this window? ----
516
548
  // OpenProcess + QueryFullProcessImageName is the ONLY safe way to ask: Process.MainModule
517
549
  // throws for any process this worker may not read (see IsHostPid).
@@ -739,6 +771,9 @@ namespace DshComputerUse.Worker
739
771
  {
740
772
  _sceneVisible = false; _preparing = false; _thinking = false; _flashStart = 0;
741
773
  _killNow = true;
774
+ // The hands-off state is over (release/calm/brake): the countdown continuity window
775
+ // must not survive into the next sequence.
776
+ Program.ResetReannounceGrace();
742
777
  }
743
778
 
744
779
  // Cyan thinking/monitoring is a lifecycle latch, with no elapsed-time expiry. Off/Kill
@@ -2870,7 +2905,12 @@ namespace DshComputerUse.Worker
2870
2905
  // "calibrate" drives too — it walks the pointer across the screen to prove pixel==click.
2871
2906
  // (selectRange / shiftClick / calibrate were found by build/test-op-classification.mjs on
2872
2907
  // its first run: three real dispatcher ops the hand-written list had simply missed.)
2873
- "calibrate"
2908
+ "calibrate",
2909
+ // "launch" starts a process: the machine changes, so it may not re-open a session the
2910
+ // human ended (Ctrl+Alt+Q) and it must not answer while a brake is up.
2911
+ "launch",
2912
+ // "focusField" clicks a field: it drives the machine exactly like a click.
2913
+ "focusField"
2874
2914
  };
2875
2915
 
2876
2916
  static readonly HashSet<string> ReadOnlyOps = new HashSet<string> {
@@ -3436,6 +3476,23 @@ namespace DshComputerUse.Worker
3436
3476
  static volatile bool _operationLive, _operationDispatched;
3437
3477
  static bool _operationActing;
3438
3478
  static string _lastAnnouncedSeries;
3479
+ // CONTINUOUS-OPERATION GRACE (human rule 2026-11-07): after one purple countdown the human
3480
+ // has been told to keep hands off. While operations keep arriving inside this sliding
3481
+ // window, the "do not intervene" state is CONTINUOUS, so no further countdown of any
3482
+ // duration is shown. Every dispatched mutation pushes the deadline forward, which is what
3483
+ // makes a real sequence exempt indefinitely; a gap longer than the window ends continuity
3484
+ // and the next action warns again. Wrap-safe comparison: both sides are TickCount deltas
3485
+ // bounded by the window itself.
3486
+ const int ReannounceGraceMs = 5000;
3487
+ static int _noReannounceUntil;
3488
+ static bool InReannounceGrace()
3489
+ { return unchecked(_noReannounceUntil - Environment.TickCount) > 0; }
3490
+ static void ExtendReannounceGrace()
3491
+ { _noReannounceUntil = unchecked(Environment.TickCount + ReannounceGraceMs); }
3492
+ /** Ends the continuity window. Called when the hands-off state itself ends (cycle release,
3493
+ * calm, brake), because a later action then genuinely starts a new sequence. */
3494
+ public static void ResetReannounceGrace()
3495
+ { _noReannounceUntil = 0; }
3439
3496
  static long _announcedHumanRevision, _announcedInputEpoch, _preparedInputEpoch;
3440
3497
  static volatile int _activeRequestId, _lastCompletedRequestId;
3441
3498
  // The seam is internal to the compiled program; no tool can replace it.
@@ -3462,12 +3519,47 @@ namespace DshComputerUse.Worker
3462
3519
  if (actual != expected) return "input receiver changed from hwnd=" + expected + " to hwnd=" + actual;
3463
3520
  return null;
3464
3521
  }
3522
+
3523
+ /// True when two windows belong to the SAME input family: the same window, another window
3524
+ /// of the same process, a descendant process's window (an embedded renderer), or a window
3525
+ /// owned/parented by the other. A modal dialog (Find/Replace, Save As, a browser file
3526
+ /// picker) is a DIFFERENT hwnd in the SAME process that takes the foreground while the
3527
+ /// operation it belongs to is still running — treating it as foreign made dialogs a
3528
+ /// keyboard black hole and blocked the screenshot that would have shown the question.
3529
+ public static bool SameInputFamily(IntPtr a, IntPtr b)
3530
+ {
3531
+ if (a == b) return true;
3532
+ if (a == IntPtr.Zero || b == IntPtr.Zero) return false;
3533
+ uint pa = PidOf(a), pb = PidOf(b);
3534
+ if (pa == 0 || pb == 0) return false;
3535
+ if (pa == pb) return true;
3536
+ if (ProcessDescendsFrom(unchecked((int)pb), unchecked((int)pa)) ||
3537
+ ProcessDescendsFrom(unchecked((int)pa), unchecked((int)pb))) return true;
3538
+ IntPtr x = b;
3539
+ for (int hop = 0; hop < 8 && x != IntPtr.Zero; hop++)
3540
+ {
3541
+ IntPtr owner = Native.GetWindow(x, 4); // GW_OWNER
3542
+ if (owner == a) return true;
3543
+ IntPtr parent = Native.GetParent(x);
3544
+ if (parent == a) return true;
3545
+ x = owner != IntPtr.Zero ? owner : parent;
3546
+ }
3547
+ return false;
3548
+ }
3549
+
3465
3550
  static IDisposable GuardReceiver(IntPtr expected, uint pid, bool keyboard)
3466
3551
  {
3467
3552
  return new ReceiverScope(delegate {
3468
3553
  IntPtr actual = keyboard ? InputForeground() : InputPointReceiver();
3469
3554
  string problem = InputReceiverProblem(expected.ToInt64(), pid, InputWindowExists(expected), InputWindowPid(expected), actual.ToInt64());
3470
- if (problem != null) throw InputInterrupted("INPUT_TARGET_CHANGED", problem + ". Remaining input was refused; inspect any partial effect before retrying.");
3555
+ if (problem != null)
3556
+ {
3557
+ // Same-family foreground for KEYBOARD only: the dialog is where the keystrokes
3558
+ // belong, so accept it and follow it. Mouse/point receivers keep the strict
3559
+ // check — a click must land on the pixel's real owner, never be retargeted.
3560
+ if (keyboard && SameInputFamily(expected, TopLevel(actual))) { SetSticky(TopLevel(actual)); return; }
3561
+ throw InputInterrupted("INPUT_TARGET_CHANGED", problem + ". Remaining input was refused; inspect any partial effect before retrying.");
3562
+ }
3471
3563
  });
3472
3564
  }
3473
3565
 
@@ -3492,6 +3584,9 @@ namespace DshComputerUse.Worker
3492
3584
  static void MarkMutation()
3493
3585
  {
3494
3586
  Panic.Check("actuation"); BeginActuation(); CheckHumanInput(); _operationDispatched = true;
3587
+ // Every dispatched mutation slides the grace window: a continuous sequence therefore
3588
+ // stays exempt indefinitely, while a real pause lets the window lapse.
3589
+ ExtendReannounceGrace();
3495
3590
  }
3496
3591
  static void BeginActuation()
3497
3592
  {
@@ -3527,6 +3622,14 @@ namespace DshComputerUse.Worker
3527
3622
  throw InputInterrupted("HUMAN_REOBSERVE", "The user changed the desktop between batch steps. Obtain fresh target evidence and continue the remaining task without replaying completed input.");
3528
3623
  _preparedInputEpoch = Panic.InputEpoch; return;
3529
3624
  }
3625
+ // Inside the sliding grace the sequence is still in progress: no countdown of any
3626
+ // duration, but a desktop change still refuses (the human may have taken over).
3627
+ if (InReannounceGrace())
3628
+ {
3629
+ if (_announcedInputEpoch != Panic.InputEpoch)
3630
+ throw InputInterrupted("HUMAN_REOBSERVE", "The user changed the desktop between actions. Obtain fresh target evidence and continue the remaining task without replaying completed input.");
3631
+ _preparedInputEpoch = Panic.InputEpoch; return;
3632
+ }
3530
3633
  Panic.Check(op); CheckHumanInput();
3531
3634
  long inputEpoch = Panic.InputEpoch;
3532
3635
  IntPtr foreground = PreparationForeground();
@@ -3553,6 +3656,7 @@ namespace DshComputerUse.Worker
3553
3656
  throw InputInterrupted("HUMAN_REOBSERVE", "The user changed the desktop during the preparation cue. No action was dispatched. Preserve the task, obtain fresh target evidence, and continue the authorized task immediately; this is not cancellation or abandonment.");
3554
3657
  _lastAnnouncedSeries = series; _announcedHumanRevision = Panic.HumanRevision;
3555
3658
  _announcedInputEpoch = _preparedInputEpoch = inputEpoch;
3659
+ ExtendReannounceGrace();
3556
3660
  }
3557
3661
  finally { Glow.Prepare(false); }
3558
3662
  }
@@ -4136,6 +4240,8 @@ namespace DshComputerUse.Worker
4136
4240
  case "type": FailsafeCheck(a); return Ok(Type(a));
4137
4241
  case "clipRead": return Ok(ClipRead(a));
4138
4242
  case "clipWrite": FailsafeCheck(a); return Ok(ClipWrite(a));
4243
+ case "launch": FailsafeCheck(a); return Ok(Launch(a));
4244
+ case "focusField": FailsafeCheck(a); return Ok(FocusField(a));
4139
4245
  case "windows": return Ok(Windows(a));
4140
4246
  case "activate": FailsafeCheck(a); return Ok(Activate(a));
4141
4247
  case "windowOp": FailsafeCheck(a); return Ok(WindowOp(a));
@@ -4840,7 +4946,20 @@ namespace DshComputerUse.Worker
4840
4946
  return dry;
4841
4947
  }
4842
4948
  if (hostVictim) throw new Exception(HostGuardMsg("click", unchecked((int)PidOf(under))));
4843
- if (blocked != null && !GetB(a, "allowCovered", false))
4949
+ bool allowCovered = GetB(a, "allowCovered", false);
4950
+ // NARROWED (2026-11-07): allowCovered is an escape for the target app's OWN embedded
4951
+ // renderer only. It used to authorise the click whatever owned the pixel, so an
4952
+ // unrelated front window could receive input meant for the target app — measured
4953
+ // live when a covered click landed on the agent's own GUI instead of WeChat. An
4954
+ // ancestor test cannot be spoofed by z-order: the receiver must be the target's
4955
+ // descendant process (WeChatAppEx under Weixin.exe, an Electron utility process).
4956
+ if (allowCovered && blocked == "COVERED" &&
4957
+ !ProcessDescendsFrom(unchecked((int)PidOf(under)), unchecked((int)PidOf(want))))
4958
+ throw new Exception("precondition failed (COVERED-UNRELATED): " + detail +
4959
+ ". allowCovered only authorises the target application's own embedded renderer " +
4960
+ "(a descendant of the target process); an unrelated window on top is never clicked. " +
4961
+ "NOTHING was clicked. Bring the target into view, or aim at a pixel inside it.");
4962
+ if (blocked != null && !allowCovered)
4844
4963
  throw new Exception("precondition failed (" + blocked + "): " + detail + ". NOTHING was clicked. " +
4845
4964
  "Bring the target into view first (activate / move / restore), or pass allowCovered:true to click anyway. " +
4846
4965
  "Use computer_uia at:\"" + tx + "," + ty + "\" for the full ground truth.");
@@ -5024,7 +5143,7 @@ namespace DshComputerUse.Worker
5024
5143
  AgentActing(a, "scroll");
5025
5144
  if (a.ContainsKey("x") && a.ContainsKey("y")) Move(a);
5026
5145
  IntPtr scrollTarget = InputPointReceiver();
5027
- if (_stickyHwnd != IntPtr.Zero && scrollTarget != _stickyHwnd && InputWindowPid(scrollTarget) != InputWindowPid(_stickyHwnd))
5146
+ if (_stickyHwnd != IntPtr.Zero && scrollTarget != _stickyHwnd && !SameInputFamily(_stickyHwnd, scrollTarget))
5028
5147
  throw InputInterrupted("INPUT_TARGET_CHANGED", "Scroll receiver differs from the observed target. No wheel input was sent.");
5029
5148
  using (GuardReceiver(scrollTarget, InputWindowPid(scrollTarget), false)) {
5030
5149
  for (int i = 0; i < lines; i++) {
@@ -5981,6 +6100,103 @@ namespace DshComputerUse.Worker
5981
6100
  return Dict("written", true, "chars", text.Length);
5982
6101
  }
5983
6102
 
6103
+ // ---------- launch: open an app / URL / folder / document in ONE op ----------
6104
+ // WHY THIS IS AN OP AND NOT A SIDE-EFFECT IN THE PLUGIN (2026-11-07, measured): the
6105
+ // plugin's first version spawned explorer from Node. It worked, but it never touched the
6106
+ // worker — so the status halo never lit, the human got no signal that the machine had just
6107
+ // been acted on, and audit/rate/cooperation had to be re-implemented by hand. This op puts
6108
+ // launch on the SAME funnel as every other actuation.
6109
+ //
6110
+ // RESOLUTION IS ShellExecute's, not ours: ProcessStartInfo.UseShellExecute resolves App
6111
+ // Paths aliases ("calc"), shell: verbs, URLs, folders and document associations exactly
6112
+ // like the Run dialog. Measured on this machine: `explorer.exe calc` opens a FILE EXPLORER
6113
+ // window (a bare word is a location to explorer), while ShellExecute("calc") starts the
6114
+ // Calculator — so the launcher, not a shell wrapper, is the correct resolver.
6115
+ //
6116
+ // INJECTION: the target crosses as ONE Executable/FileName value. No command line is
6117
+ // composed, no shell interpreter is involved, and nothing is appended after the target —
6118
+ // the same argv-boundary argument the plugin's validate() documents.
6119
+ static Dictionary<string, object> Launch(Dictionary<string, object> a)
6120
+ {
6121
+ string target = GetS(a, "target", "");
6122
+ if (target.Length == 0) throw new Exception("LAUNCH_BAD_TARGET: target is empty");
6123
+ // Optional arguments (2026-11-07). A dedicated browser profile is a LAUNCHER argument,
6124
+ // not part of the target: without this the only route to `msedge.exe --user-data-dir=…`
6125
+ // was Win+R, which the input guard reports as INPUT_TARGET_CHANGED after the chord has
6126
+ // already landed and then refuses screenshots of the dialog it just opened.
6127
+ string arguments = LaunchArguments(a);
6128
+ // Same gate as every other mutation: brake/pause/human-input checks run BEFORE the
6129
+ // process is started, and the deep steady blue says "the machine is being acted on".
6130
+ AgentActing(a, "launch");
6131
+ MarkMutation();
6132
+ System.Diagnostics.Process started = null;
6133
+ try
6134
+ {
6135
+ var info = new System.Diagnostics.ProcessStartInfo(target) { UseShellExecute = true };
6136
+ if (arguments.Length != 0) info.Arguments = arguments;
6137
+ started = System.Diagnostics.Process.Start(info);
6138
+ }
6139
+ catch (Exception e)
6140
+ {
6141
+ throw new Exception("LAUNCH_FAILED: " + e.Message);
6142
+ }
6143
+ // A launch returns in milliseconds; a 5 ms flash is invisible to a human. Hold the lit
6144
+ // border for a short, SELF-EXPIRING tail (Glow.Touch) instead of a latch, so this can
6145
+ // never claim the machine is still being driven after the action has finished.
6146
+ int tailMs = GetI(a, "tailMs", 2500);
6147
+ if (tailMs > 0)
6148
+ {
6149
+ tailMs = Math.Min(10000, tailMs);
6150
+ Glow.Enabled = true;
6151
+ Glow.Touch(tailMs);
6152
+ }
6153
+ int pid = 0;
6154
+ try { if (started != null) pid = started.Id; } catch { pid = 0; }
6155
+ return Dict("launched", true, "target", target, "pid", pid, "glowMs", Math.Max(0, tailMs));
6156
+ }
6157
+
6158
+ /// Quote ONE argument the way CommandLineToArgvW parses it back. Quoting happens here,
6159
+ /// exactly once: the caller passes an array of values, never a pre-built command line.
6160
+ static string QuoteArgument(string s)
6161
+ {
6162
+ if (s.Length > 0 && s.IndexOfAny(new[] { ' ', '\t', '"' }) < 0) return s;
6163
+ var sb = new StringBuilder();
6164
+ sb.Append('"');
6165
+ int backslashes = 0;
6166
+ foreach (char c in s)
6167
+ {
6168
+ if (c == '\\') { backslashes++; continue; }
6169
+ if (c == '"') { sb.Append('\\', backslashes * 2 + 1); sb.Append('"'); backslashes = 0; continue; }
6170
+ if (backslashes > 0) { sb.Append('\\', backslashes); backslashes = 0; }
6171
+ sb.Append(c);
6172
+ }
6173
+ if (backslashes > 0) sb.Append('\\', backslashes * 2);
6174
+ sb.Append('"');
6175
+ return sb.ToString();
6176
+ }
6177
+
6178
+ /// Validate the optional `args` array and turn it into one Windows command line.
6179
+ static string LaunchArguments(Dictionary<string, object> a)
6180
+ {
6181
+ object raw;
6182
+ if (!a.TryGetValue("args", out raw) || raw == null) return "";
6183
+ if (raw is string) throw new Exception("LAUNCH_BAD_ARGS: args must be an array of strings, not a string");
6184
+ var list = raw as System.Collections.IEnumerable;
6185
+ if (list == null) throw new Exception("LAUNCH_BAD_ARGS: args must be an array of strings");
6186
+ var parts = new List<string>();
6187
+ foreach (object item in list)
6188
+ {
6189
+ if (item == null) throw new Exception("LAUNCH_BAD_ARGS: null argument");
6190
+ string s = Convert.ToString(item);
6191
+ if (s.Length == 0) throw new Exception("LAUNCH_BAD_ARGS: empty argument");
6192
+ if (s.Length > 2000) throw new Exception("LAUNCH_BAD_ARGS: argument longer than 2000 characters");
6193
+ if (s.IndexOf('\0') >= 0) throw new Exception("LAUNCH_BAD_ARGS: NUL in argument");
6194
+ parts.Add(QuoteArgument(s));
6195
+ }
6196
+ if (parts.Count > 64) throw new Exception("LAUNCH_BAD_ARGS: more than 64 arguments");
6197
+ return string.Join(" ", parts);
6198
+ }
6199
+
5984
6200
  // ---------- windows ----------
5985
6201
  static Dictionary<string, object> RectDict(RECT r)
5986
6202
  {
@@ -6104,16 +6320,25 @@ namespace DshComputerUse.Worker
6104
6320
  // guard, which must re-assert the same window before typing).
6105
6321
  string how = RefocusWindow(h, GetB(a, "clickFallback", true));
6106
6322
  uint confirmedPid = 0;
6107
- if (how == null || !Native.IsWindow(h) || !IsForeground(h) || (confirmedPid = PidOf(h)) == 0) how = "failed";
6323
+ long modalOf = 0;
6324
+ IntPtr foreground = Native.GetForegroundWindow();
6325
+ // A MODAL DIALOG of the same app becomes the real input receiver (Find/Replace, Save As,
6326
+ // a print dialog). Confirming the parent instead left every later key and screenshot
6327
+ // refused while the app was literally waiting for an answer.
6328
+ IntPtr dialog = (foreground != IntPtr.Zero && foreground != h && SameInputFamily(h, foreground)) ? foreground : IntPtr.Zero;
6329
+ IntPtr confirmed = dialog != IntPtr.Zero ? dialog : h;
6330
+ if (how == null || !Native.IsWindow(h) || (confirmedPid = PidOf(confirmed)) == 0 || !IsForeground(confirmed)) how = "failed";
6108
6331
  else
6109
6332
  {
6110
- SetSticky(h);
6111
- _kickoffFocus.Confirm(h.ToInt64(), confirmedPid);
6333
+ if (dialog != IntPtr.Zero) modalOf = h.ToInt64();
6334
+ SetSticky(confirmed);
6335
+ _kickoffFocus.Confirm(confirmed.ToInt64(), confirmedPid);
6112
6336
  }
6113
6337
  CheckHumanInput();
6114
6338
  if (how != "failed") _keyboardReobserveRequired = false;
6115
6339
  return Dict("activated", how != "failed", "activatedVia", how,
6116
- "hwnd", h.ToInt64(), "pid", confirmedPid == 0 ? null : (object)confirmedPid,
6340
+ "hwnd", confirmed.ToInt64(), "pid", confirmedPid == 0 ? null : (object)confirmedPid,
6341
+ "modalOf", modalOf == 0 ? null : (object)modalOf,
6117
6342
  "hint", how == "failed"
6118
6343
  ? "foreground lock refused every method; click the window (or its taskbar button) and retry"
6119
6344
  : null);
@@ -6158,7 +6383,9 @@ namespace DshComputerUse.Worker
6158
6383
  long target = _kickoffFocus.Target;
6159
6384
  string problem = _kickoffFocus.CaptureProblem(true, false, false,
6160
6385
  Native.IsWindow(new IntPtr(target)), foreground.ToInt64(), pid, false);
6161
- return problem == null ? target : 0;
6386
+ if (problem == null) return target;
6387
+ // Same-family foreground (a dialog of this app) is the target now.
6388
+ return target != 0 && SameInputFamily(new IntPtr(target), foreground) ? foreground.ToInt64() : 0;
6162
6389
  }
6163
6390
  static void ValidateKickoffCapture(Dictionary<string, object> a)
6164
6391
  {
@@ -6168,6 +6395,9 @@ namespace DshComputerUse.Worker
6168
6395
  IntPtr fg = TopLevel(Native.GetForegroundWindow());
6169
6396
  string problem = _kickoffFocus.CaptureProblem(Panic.CycleLit, Panic.Stopped, Panic.Exited,
6170
6397
  target != IntPtr.Zero && Native.IsWindow(target), fg.ToInt64(), PidOf(fg), IsHostWindow(fg));
6398
+ // A dialog of the same app is the target now (its hwnd replaced the one we confirmed):
6399
+ // refusing there blocked the very screenshot that shows what the dialog is asking.
6400
+ if (problem != null && target != IntPtr.Zero && SameInputFamily(target, fg)) return;
6171
6401
  if (problem != null)
6172
6402
  {
6173
6403
  // NAME THE ACTUAL FOREGROUND (test report 2026-11-04): "identify the intended
@@ -6489,6 +6719,39 @@ namespace DshComputerUse.Worker
6489
6719
  return IsHostPid(unchecked((int)PidOf(h)));
6490
6720
  }
6491
6721
 
6722
+ /// True when `child` is `ancestor` itself or a descendant process of it (bounded walk).
6723
+ /// This is the ONLY relationship that makes a covered click legitimate: the target app's
6724
+ /// own embedded renderer (e.g. WeChatAppEx under Weixin.exe). An unrelated window that
6725
+ /// happens to be on top must never receive the input.
6726
+ public static bool ProcessDescendsFrom(int child, int ancestor)
6727
+ {
6728
+ if (child <= 0 || ancestor <= 0) return false;
6729
+ if (child == ancestor) return true;
6730
+ IntPtr snapshot = Native.CreateToolhelp32Snapshot(Native.TH32CS_SNAPPROCESS, 0);
6731
+ if (snapshot == IntPtr.Zero || snapshot == new IntPtr(-1)) return false;
6732
+ try
6733
+ {
6734
+ uint size = (uint)Marshal.SizeOf(typeof(Native.PROCESSENTRY32W));
6735
+ int pid = child;
6736
+ for (int hop = 0; hop < 16 && pid > 0; hop++)
6737
+ {
6738
+ var entry = new Native.PROCESSENTRY32W();
6739
+ entry.dwSize = size;
6740
+ if (!Native.Process32FirstW(snapshot, ref entry)) return false;
6741
+ int parent = 0;
6742
+ do
6743
+ {
6744
+ if (unchecked((int)entry.th32ProcessID) == pid) { parent = unchecked((int)entry.th32ParentProcessID); break; }
6745
+ } while (Native.Process32NextW(snapshot, ref entry));
6746
+ if (parent <= 0) return false;
6747
+ if (parent == ancestor) return true;
6748
+ pid = parent;
6749
+ }
6750
+ return false;
6751
+ }
6752
+ finally { try { Native.CloseHandle(snapshot); } catch { } }
6753
+ }
6754
+
6492
6755
  /// Did the host identity reach us from the launcher? The keyboard half of the human-presence
6493
6756
  /// monitor is gated on it (see Panic.MonitorHuman / HookCallback): without it we cannot
6494
6757
  /// exempt "the human is talking to the agent in the chat", and braking on every chat message
@@ -6963,6 +7226,113 @@ namespace DshComputerUse.Worker
6963
7226
 
6964
7227
 
6965
7228
 
7229
+ // ---------- focus: put the keyboard somewhere, and PROVE it before typing ----------
7230
+ // Typing follows the focused CONTROL, not the foreground window: activating Word's Find
7231
+ // dialog and typing still landed in the document (measured 2026-11-07 — "abc" was inserted
7232
+ // into the .doc while the field stayed empty). This op clicks the field, reports where focus
7233
+ // actually went (UIA focused element), and whether a caret is provable there, so a caller
7234
+ // can refuse to type into an unproven field instead of discovering it in the file.
7235
+ static Dictionary<string, object> FocusedElementReport()
7236
+ {
7237
+ try
7238
+ {
7239
+ AutomationElement fe = AutomationElement.FocusedElement;
7240
+ if (fe == null) return Dict("available", false);
7241
+ string type = null, name = null, id = null;
7242
+ try { type = fe.Current.ControlType.ProgrammaticName; } catch { }
7243
+ try { name = fe.Current.Name; } catch { }
7244
+ try { id = fe.Current.AutomationId; } catch { }
7245
+ return Dict("available", true, "controlType", type, "name", Short(name, 48), "automationId", id);
7246
+ }
7247
+ catch (Exception e) { return Dict("available", false, "error", e.GetType().Name); }
7248
+ }
7249
+
7250
+ static Dictionary<string, object> CaretReport()
7251
+ {
7252
+ try
7253
+ {
7254
+ Native.GUITHREADINFO info = new Native.GUITHREADINFO();
7255
+ info.cbSize = Marshal.SizeOf(typeof(Native.GUITHREADINFO));
7256
+ IntPtr fg = Native.GetForegroundWindow();
7257
+ uint pid;
7258
+ uint thread = Native.GetWindowThreadProcessId(fg, out pid);
7259
+ if (thread == 0 || !Native.GetGUIThreadInfo(thread, ref info) || info.hwndCaret == IntPtr.Zero)
7260
+ return Dict("available", false);
7261
+ POINT p = new POINT();
7262
+ p.X = info.rcCaret.Left; p.Y = info.rcCaret.Top;
7263
+ Native.ClientToScreen(info.hwndCaret, ref p);
7264
+ return Dict("available", true, "x", p.X, "y", p.Y,
7265
+ "height", Math.Max(1, info.rcCaret.Bottom - info.rcCaret.Top));
7266
+ }
7267
+ catch (Exception e) { return Dict("available", false, "error", e.GetType().Name); }
7268
+ }
7269
+
7270
+ static Dictionary<string, object> FocusField(Dictionary<string, object> a)
7271
+ {
7272
+ int x = GetI(a, "x", int.MinValue), y = GetI(a, "y", int.MinValue);
7273
+ if (x == int.MinValue || y == int.MinValue)
7274
+ throw new Exception("FOCUS_POINT_REQUIRED: focus needs the field's x,y in screen pixels; nothing was clicked");
7275
+ IntPtr want = a.ContainsKey("hwnd") && GetI(a, "hwnd", 0) != 0 ? new IntPtr(GetI(a, "hwnd", 0)) : IntPtr.Zero;
7276
+ AgentActing(a, "focusField");
7277
+ MarkMutation();
7278
+ if (want != IntPtr.Zero) { RefocusWindow(want, true); Thread.Sleep(60); }
7279
+ AnimatedMove(x, y, GetI(a, "dur", 140));
7280
+ Thread.Sleep(GetI(a, "settleMs", 90));
7281
+ IntPtr under = InputPointReceiver();
7282
+ IntPtr target = want != IntPtr.Zero ? want : TopLevel(under);
7283
+ if (!SameInputFamily(target, under))
7284
+ throw new Exception("precondition failed (COVERED): the pixel belongs to \"" + Short(WinTitleOf(under), 40) + "\" (pid " +
7285
+ PidOf(under) + "), not to the window you aimed at. NOTHING was clicked.");
7286
+ using (GuardReceiver(under, InputWindowPid(under), false))
7287
+ {
7288
+ SendMouse(Native.MOUSEEVENTF_LEFTDOWN, 0, 0, 0);
7289
+ Thread.Sleep(30);
7290
+ SendMouse(Native.MOUSEEVENTF_LEFTUP, 0, 0, 0);
7291
+ }
7292
+ // The click moved focus inside this app: confirm the foreground as the observation
7293
+ // target, or the capture that must prove the field would be refused as unconfirmed.
7294
+ IntPtr fgTop = TopLevel(Native.GetForegroundWindow());
7295
+ if (fgTop != IntPtr.Zero)
7296
+ {
7297
+ SetSticky(fgTop);
7298
+ _kickoffFocus.Confirm(fgTop.ToInt64(), PidOf(fgTop));
7299
+ }
7300
+ EndActuationForReadback();
7301
+ Thread.Sleep(GetI(a, "readDelayMs", 180));
7302
+ Dictionary<string, object> focused = FocusedElementReport();
7303
+ Dictionary<string, object> caret = CaretReport();
7304
+ object rawType;
7305
+ string controlType = focused.TryGetValue("controlType", out rawType) ? Convert.ToString(rawType) : null;
7306
+ bool textTarget = controlType != null &&
7307
+ (controlType.IndexOf("Edit", StringComparison.OrdinalIgnoreCase) >= 0 ||
7308
+ controlType.IndexOf("Document", StringComparison.OrdinalIgnoreCase) >= 0);
7309
+ bool caretVisible = caret.ContainsKey("available") && Convert.ToBoolean(caret["available"]);
7310
+ // UNCERTAIN CASE: the OS reports no caret (browser content, some editors). Fall back to
7311
+ // the one property a caret always has — it blinks: two tight crops 260 ms apart differ.
7312
+ bool blink = false;
7313
+ if (!caretVisible && GetB(a, "verifyCaret", true))
7314
+ {
7315
+ int bx = Math.Max(0, x - 30), by = Math.Max(0, y - 16);
7316
+ byte[] first = FrameSignatureRect(bx, by, 60, 32, 2);
7317
+ Thread.Sleep(260);
7318
+ byte[] second = FrameSignatureRect(bx, by, 60, 32, 2);
7319
+ blink = first != null && second != null && first.Length == second.Length && SigDiffPct(first, second, 8) >= 0.05;
7320
+ }
7321
+ bool verified = textTarget && (caretVisible || blink);
7322
+ return Dict(
7323
+ "clicked", Dict("x", x, "y", y),
7324
+ "receiver", under == IntPtr.Zero ? null : (object)(Short(WinTitleOf(under), 40) + " (pid " + PidOf(under) + ")"),
7325
+ "focused", focused,
7326
+ "textTarget", textTarget,
7327
+ "caret", caret,
7328
+ "caretVerified", caretVisible,
7329
+ "caretBlink", blink,
7330
+ "verified", verified,
7331
+ "reason", verified ? null : (textTarget
7332
+ ? "focus reached a text control but no caret is provable; capture the field, click it again, and re-check before typing"
7333
+ : "focus landed on a non-text control (" + (controlType ?? "unknown") + "); click a field before typing"));
7334
+ }
7335
+
6966
7336
  static Dictionary<string, object> WindowOp(Dictionary<string, object> a)
6967
7337
  {
6968
7338
  IntPtr h = FindHwnd(a);
@@ -43,10 +43,16 @@ export async function attachScreenshot (ctx, exec, value, data, options = {}) {
43
43
  value.imageReason = reason
44
44
  value.view = reason === 'model-text-only'
45
45
  ? 'Image not attached: this model does not declare image input. Use UIA/text or switch to an image-capable model.'
46
- : 'Image not attached. Use read_image on path if visual inspection is needed.'
46
+ : reason === 'delivery-path'
47
+ ? 'Path delivery requested: no image block was attached. The file is on path; call read_image only if visual inspection is actually needed.'
48
+ : 'Image not attached. Use read_image on path if visual inspection is needed.'
47
49
  if (value.settled === false) value.view = 'NOT settled — re-shoot. ' + value.view
48
50
  return value
49
51
  }
52
+ // delivery:"path" — the caller has another way to verify (UIA/OCR/window text), so the pixels
53
+ // are not evidence it will read. The file is still written and the path still returned; only
54
+ // the image block is withheld, which is what keeps it out of the re-sent conversation history.
55
+ if (options.delivery === 'path') return fallback('delivery-path')
50
56
  try {
51
57
  if (!validImageSize(value.imageSize)) return fallback('invalid-source-image-size')
52
58
  const sourceSize = [...value.imageSize]
@@ -107,7 +113,12 @@ export function renderScreenshot (value) {
107
113
 
108
114
  // A batch exposes one latest image as its checkpoint. Earlier explicit captures
109
115
  // keep their paths for deliberate later reads, without multiplying vision input.
110
- export function selectBatchScreenshot (result, automatic, kind = 'image') {
116
+ //
117
+ // `delivery` is the batch-level request, and it has to reach further than the checkpoint: when
118
+ // no checkpoint happens (shot:"never", a readback tail, or a later failure) the observation
119
+ // falls back to the last explicit step image. A batch that asked for paths must not hand one
120
+ // back as a picture just because the checkpoint was skipped — same pixels, quieter route.
121
+ export function selectBatchScreenshot (result, automatic, kind = 'image', delivery = 'auto') {
111
122
  let latest = automatic
112
123
  let index
113
124
  if (!latest) {
@@ -118,19 +129,30 @@ export function selectBatchScreenshot (result, automatic, kind = 'image') {
118
129
  }
119
130
  }
120
131
  }
132
+ const pathDelivery = delivery === 'path'
133
+ const pathNote = 'Path delivery requested: no image block was attached. Use read_image on this path only if these pixels are the evidence.'
121
134
  for (const action of result.actions) {
122
135
  const shot = action.result
123
136
  if (shot?.image && shot !== latest) {
124
137
  delete shot.image
125
138
  restoreFileGeometry(shot)
126
139
  shot.imageStatus = 'path-only'
127
- shot.imageReason = 'earlier-batch-image'
128
- shot.view = kind === 'state'
129
- ? 'A newer UIA state is returned. This earlier image remains at its path.'
130
- : 'Only the latest batch screenshot is attached. Use read_image on this path if this earlier frame is needed.'
140
+ shot.imageReason = pathDelivery ? 'delivery-path' : 'earlier-batch-image'
141
+ shot.view = pathDelivery
142
+ ? pathNote
143
+ : kind === 'state'
144
+ ? 'A newer UIA state is returned. This earlier image remains at its path.'
145
+ : 'Only the latest batch screenshot is attached. Use read_image on this path if this earlier frame is needed.'
131
146
  }
132
147
  }
133
148
  if (latest) {
149
+ if (pathDelivery && latest.image) {
150
+ delete latest.image
151
+ restoreFileGeometry(latest)
152
+ latest.imageStatus = 'path-only'
153
+ latest.imageReason = 'delivery-path'
154
+ latest.view = pathNote
155
+ }
134
156
  result.observation = latest
135
157
  if (kind === 'state') result.observationSource = 'automatic final UIA state'
136
158
  else result.imageSource = automatic ? 'automatic final screenshot' : `action ${index + 1}; captured before any later actions`