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.
- package/README.ar.md +234 -0
- package/README.de.md +234 -0
- package/README.es.md +264 -0
- package/README.fa.md +234 -0
- package/README.fr.md +243 -0
- package/README.md +41 -8
- package/README.pt.md +234 -0
- package/README.ru.md +259 -0
- package/README.zh.md +41 -8
- package/icon.svg +20 -4
- package/lib/bin/attention-worker.exe +0 -0
- package/lib/bin/attention-worker.exe.json +1 -1
- package/lib/core/bin/dsh-computer-use-worker.exe +0 -0
- package/lib/core/bin/dsh-computer-use-worker.exe.json +2 -2
- package/lib/core/index.js +31 -0
- package/lib/core/worker.cs +380 -10
- package/lib/image-output.js +28 -6
- package/lib/launch.js +67 -0
- package/lib/policy.js +22 -2
- package/lib/tools.js +74 -8
- package/package.json +5 -1
- package/scripts/verify-package.mjs +9 -0
- package/skills/computer-use/SKILL.md +50 -50
package/lib/core/worker.cs
CHANGED
|
@@ -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
|
|
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)
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
6111
|
-
|
|
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",
|
|
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
|
-
|
|
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);
|
package/lib/image-output.js
CHANGED
|
@@ -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
|
-
:
|
|
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
|
-
|
|
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 =
|
|
129
|
-
?
|
|
130
|
-
:
|
|
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`
|