Skip to content

Releases: xie129716/computer-user-vision

v0.4.0 - renamed to computer-user-vision

Choose a tag to compare

@xie129716 xie129716 released this 15 Sep 07:36

Renamed to computer-user-vision

The package no longer shares upstream's npm name. It was computer-user on purpose — so a profile
could drop this fork in exactly where the original sat — but a shared name makes every npm-keyed
reader
resolve upstream instead:

  • The plugin market showed upstream's 0.3.6 version and its "pairs with picturereader
    (image_scan/image_ocr)"
    description on this fork's card, because it looks the package up by name.
  • Installing by that name got upstream's code — the version that does not load on current DSH.

Distinct names cost the drop-in and buy correct identity.

What moved with the name

Nothing is left half-renamed:

  • package.json name, and cordis.patch.yml's row id and loader specifier.
  • The settings namespace (computer-user → computer-user-vision). Left alone, two plugins
    installed side by side would both register the same namespace and one would fail to load.
  • The control route (/computer-user/control → /computer-user-vision/control) and the
    approval store ($DSH_HOME/computer-user-vision-approvals.json), for the same collision reason.
  • The doctor script (tools/computer-user-vision-doctor.mjs), plus the package paths in
    tools/install.mjs and verify/_profile.mjs.

Upgrading

The dependency key changes, so remove then add:

dsh plugin --profile web remove computer-user
dsh plugin --profile web add computer-user-vision

Settings and approvals do not carry over. Neither file existed for a released build, so there is
nothing to migrate.

Assets

computer-user-vision.tgz is the canonical, version-free asset. computer-user.tgz carries the same
bytes and is kept so links written before the rename keep resolving.

Nothing else changed: the vision adaptation, the element refs, the control indicator and the stop
latch are all as they were in 0.3.32.

v0.3.32 — the suite was littering the desktop it tests

Choose a tag to compare

@xie129716 xie129716 released this 14 Sep 09:11

No plugin code changed. verify/general-workflows.mjs spawned calc.exe on every run, and when the
Calculator is already running, launching calc.exe opens another window instead of reusing the
app.

Measured after a session of runs: one Calculator process and 38 top-level windows titled
计算器. A test that litters the machine it is supposed to be testing is a bad neighbour, and it also
makes computer_list_windows noisy for whoever runs it next — the plugin's own window list becomes a
wall of near-identical entries.

It now reuses a Calculator that is already up, and shuts down the one it launched when it is done —
only that one, since closing the app takes every window of it with it and one the user had open is
not ours to close.

Verified: two consecutive runs leave 0 windows and 0 processes behind, both green at 18/18.

v0.3.31 — using the new feature found two things the tests could not

Choose a tag to compare

@xie129716 xie129716 released this 14 Sep 09:07

Both of these came from driving the plugin through a restarted live session rather than through the
verifier — which is the point of restarting instead of trusting a green suite.

The snapshot id was never shown to the model. computer_elements and computer_screenshot render
their own text rather than the generic key: value renderer, and the snapshot field lived only in
the STRUCTURED value, which the model never sees. An id it cannot see is an id it can never pin, so
the whole snapshot: parameter was unusable in practice. Both renderers now print:

snapshot: s2 (pass snapshot:"s2" to computer_click / computer_type to pin these refs to this enumeration)

verify/ref-snapshots.mjs now asserts on the rendered text, not on the object — that was precisely
the layer the first version of the test checked by mistake.

Activating a packaged app by its own pid could not work. A UWP app's
Windows.UI.Core.CoreWindow is the largest window its process owns, so a pid lookup picks exactly the
one window Windows refuses to foreground: the result was a correct but useless activated: false.

Measured first, and it killed the obvious fix — GetAncestor(coreWindow, GA_ROOT) returns the
CoreWindow itself, so it is a root window and climbing to the root changes nothing. The frame that
hosts it belongs to ApplicationFrameHost, and WindowHostingPid already knew how to find it — but it
was only consulted when the process owned no usable window, so the common case never reached it. It
is now a fallback when activation does not take.

The substitution is reported rather than silent (resolved_from plus a note naming both handles),
because requested alone would leave the caller believing its own handle came forward. Root() is
kept for what it does fix: a caller that names a genuine CHILD window, which SetForegroundWindow
ignores outright.

computer_activate_window advertised {requested, foreground, activated} but only set activated
on FAILURE
, so absence did double duty as success. It now reports activated: true.

verify/general-workflows.mjs grew to 18 checks (A6 reach-a-real-window, A7 report-the-substitution).

v0.3.30 — a shipped verifier that failed about one run in three

Choose a tag to compare

@xie129716 xie129716 released this 14 Sep 07:55

No plugin code changed. verify/general-workflows.mjs D2 — "a refused call sends no input at all" —
failed intermittently, and the cause was the harness, not the plugin.

Set-Clipboard fails with CLIPBRD_E_CANT_OPEN whenever another process holds the clipboard open,
and that happens often enough to be roughly one run in three. The helper ignored the error, so the
check then read the PREVIOUS clipboard contents — the user's own 2026/9/9 — and reported it as
"input was sent". A test that blames the code for a clipboard race is worse than no test.

setClip now retries and confirms the write took, and D2 asserts the property directly (the
refused text appears nowhere) with the strong "clipboard is byte-for-byte unchanged" form used when
priming succeeded — and it says which form ran.

Measured 4/4 green afterwards, against 1 failure in 3 before.

Worth recording because it is the second time in this series that a red result was the test's own
fault rather than the plugin's, and the first Set-Clipboard failure was silently indistinguishable
from a real one.

v0.3.29 — snapshot-scoped element refs

Choose a tag to compare

@xie129716 xie129716 released this 14 Sep 07:49

Every enumeration now returns a snapshot id (s7), and refs are stored per snapshot instead of
in a rolling pile where a name could quietly change meaning. Retention is 3 enumerations, which keeps
screenshot -> computer_elements -> click working and nothing older.

computer_click and computer_type take an optional snapshot: to pin. A pinned ref resolves
inside exactly that enumeration, and a pin to an evicted snapshot is refused as stale rather than
silently re-pointed:

快照 s999999 已过期(只保留最近 3 次枚举:s2, s1)。引用只在产生它的那次枚举内有效,请重新
computer_screenshot / computer_elements 后再操作。

A bare ref follows the newest enumeration that contains it, which is the list the caller just
read. The result now reports ref_snapshot — which enumeration it came from — and ref_ambiguous
when an older live snapshot disagreed about that name:

引用 e1 在更早的快照里指向不同控件(s3 -> "计算器");本次按最新快照 s4 解析。若你本意是更早
那次,请带上 snapshot 明确指定。

purpose: "look" creates no snapshot, because it enumerates nothing. A look capture must not
shadow refs the caller is already holding, and it does not.

The first implementation of this refused — and that was measured wrong

It rejected any bare ref that two live snapshots disagreed about, and
verify/element-refs.mjs --click broke on its very first integration run: that test enumerates the
focused window, launches its own Notepad, enumerates that, then clicks e1 — unambiguously meaning
the Notepad it just enumerated — and the refusal rejected a correct call.

So a contest became a report rather than a refusal, and refusing is reserved for what genuinely
cannot be honoured: an unknown ref, or a pin to an evicted snapshot. Recorded because the strict
version looked obviously right until it was run.

New verify/ref-snapshots.mjs (12 checks)

Proves all of the above against the live desktop, including that pinning an older snapshot resolves
to that enumeration rather than the newest.

This is a stability change, not a speed change

It costs one resolveRef per call — no measurable latency — and it does not touch the real
bottlenecks (process spawn, UI Automation enumeration, capture). What it buys is that a ref can no
longer silently mean a different control, and that a caller can say which enumeration it meant.

v0.3.28 — 0.3.27 over-fitted to the one window it was found on

Choose a tag to compare

@xie129716 xie129716 released this 13 Sep 17:23

0.3.27 refused to enumerate any window whose root element reports no rectangle. That was an
over-fit, and it is reverted.

It was written against a single observed case — a minimized ToDesk window whose root
BoundingRectangle is Rect.Empty — but a host can report Rect.Empty for the root while its
children carry perfectly usable rectangles. Returning early therefore threw away windows that are
perfectly enumerable: exactly the "fix the case in front of you, break the general one" change a
general-purpose plugin cannot afford.

The behaviour now is: enumerate always. rootW/rootH of 0 means unknown, and the only place
that consults it — the "a nameless element covering the whole window is a container" filter — stands
down when the window's own size is unknown, because at 0 it would match every element. If nothing is
found and the rectangle was missing, the caller gets the missing rectangle as the explanation. A
window with no rectangle and real children returns its children.

New verify/general-workflows.mjs (17 checks)

The plugin is not for any one application, so this drives the whole interaction surface against
four unrelated UI stacks and asserts an observable outcome each time, never "the call returned ok":

group stack what it proves
A window manager inventory rectangles are consistent; activate by hwnd / title / pid; a packaged app does not resolve to its 0x0 helper; a bogus pid is refused with a reason
B plain Win32 (Notepad) refs enumerate; a click by ref lands on the ref's exact centre; mixed ASCII + CJK + Cyrillic typed text round-trips through the clipboard; the wheel really scrolls (proved by a luminance profile of the page); a press-and-hold drag really selects
C UWP/XAML (Calculator) live pattern flags are present (the cached-flag regression cannot return); clicking a digit changes the display's accessible name
D guards expect_window refuses on a mismatch and sends no input; an unknown ref is refused

Three of its first-run failures were the test's own bugs, and two are worth recording because they
would mislead anyone repeating this:

  • equal-length lines make a band-luminance profile periodic, so a document can scroll a long way
    with the profile unmoved — the document has to be non-uniform for the oracle to mean anything;
  • a digit button must be identified by automationId (num4Button), not by accessible name: the
    name is localised, and on a Chinese UI matching 0-9 finds nothing at all.

Two general behaviours recorded, not changed

computer_activate_window by the pid of ApplicationFrameHost is genuinely ambiguous — that process
hosts several frames at once (measured: 计算器 and 设置 both belong to it), so it resolves to one of
them and there is no way to know which the caller meant. Pass the app's own pid, an hwnd, or a title.

pointer_landed is a display string like [995,558], consistent with target and under_cursor,
not an array.

v0.3.27 — a window with no rectangle took the whole tool call down

Choose a tag to compare

@xie129716 xie129716 released this 13 Sep 17:15

Found by running verify/element-refs.mjs against a minimized ToDesk window, which the desktop
happened to leave in the foreground.

A BoundingRectangle of Rect.Empty aborted every enumeration with exit 1. That property is a
System.Windows.Rect, and an element with no on-screen area — minimized, hidden, or owned by a
remote-control session — reports Rect.Empty, whose Width and Height are
double.NegativeInfinity. Casting that to [int] throws "value too large or too small for an
Int32"
, and because the cast sat in the middle of the screenshot path, computer_screenshot
returned a hard error rather than a result.

It now says why instead of crashing:

UI Automation is unavailable for this window: the window reports no on-screen rectangle
(minimized, hidden, or a remote-control session), so there are no controls to enumerate

The same shape is guarded on the two other BoundingRectangle reads — the per-element loop (which
already checked IsEmpty) and the "what is under the cursor" report. Since an empty root rectangle
now returns early, the "element covers the whole window" filter no longer has to defend against
rootW/rootH being 0.

Nothing else changed; the 0.3.26 indicator fixes are unaffected.

v0.3.26 — the banner stopped eating clicks, and the brake works again

Choose a tag to compare

@xie129716 xie129716 released this 13 Sep 17:04

Two bugs in the control indicator, both found by measuring rather than looking. The second is why this
release exists: the emergency Stop button had been silently dead.

The banner swallowed a 620x46 strip of the screen

The banner is decoration plus one button, but it was a single window, so every pixel of it consumed a
mouse click. A click on the Ctrl+Alt+Esc hint — which reads as a caption — reached nothing. This was
hit for real: a click near the top of the screen went nowhere.

The obvious fix was tried first and measured to break the brake, so it was reverted instead of
shipped: adding WS_EX_TRANSPARENT to the banner made all four probe points inside the Stop button
return the window underneath. A WS_EX_LAYERED window is hit-tested as a whole layer, so the
style takes the children with it.

The fix that works is structural — one window becomes two:

window contents click behaviour
deco background, accent bar, both labels click-through (layered + transparent)
brake nothing but the Stop button, exactly 96x30 the only part that can take a click

Measured with WindowFromPoint, which is the same question Windows asks when routing a click:

banner strip points eaten interactive area
before 10 / 10 620x46 = 28 520 px²
after 0 / 10 96x30 = 2 880 px²

Every non-button point now resolves to the window underneath. The brake stays where it was, at
[1162,18 .. 1258,48].

The Stop button had silently stopped working

Splitting the banner broke the brake, and the symptom was not a missing click. A message-level trace
showed WM_LBUTTONDOWN and WM_LBUTTONUP both arriving at the button, with the control's own
state machine running ENTER -> DOWN -> UP — and no Click.

The cause was one line in the 50 ms animation tick that re-asserted TopMost on both windows. Because
deco overlaps brake (the button sits inside the banner strip), that slid a window between the
cursor and the button mid-press
and WinForms cancelled the press. The old single-window banner was
immune only because the window it re-asserted was the button's own parent — which is exactly why the
split introduced it, and why bisection found it and reading did not. It now only repairs the z-order
when it has actually been lost.

The 96x30 brake window came out 136x39

Windows clamps a top-level window to SM_CXMIN x SM_CYMIN — measured here as 136x39 — because a
Form with FormBorderStyle='None' is still a plain overlapped window, not WS_POPUP. WinForms' own
MinimumSize was 0x0, so this is the OS, not the framework. The clamp is applied when the bounds are
set, not by SetWindowPos, so a direct SetWindowPos after the handle exists is the fix.

New verifier: verify/overlay-hit.mjs (9 checks)

The lifecycle test proves the indicator starts and stops; it cannot see either bug above. This one asks
the routing question directly and is the instrument that caught the reverted fix before it shipped.
It also fires one real synthetic click and requires the stop marker to say button.

verify/overlay.mjs and verify/overlay-hit.mjs both had a process filter matching a bare
overlay.ps1 substring, which also matched the shell that launched them — killing the test runner
mid-run. Both now match the real spawn shape, -File ... overlay.ps1.

v0.3.25 — purpose:look: the cheap way to just see the screen

Choose a tag to compare

@xie129716 xie129716 released this 13 Sep 16:25

Prompted by measuring what a "read the screen" call actually costs across the tool boundary.

  • New purpose parameter on computer_screenshot. "look" means "just show me the screen" and
    turns on the whole cheap recipe at once: no element enumeration, a JPEG instead of a PNG, and scale
    0.35. "inspect" (the default) keeps the refs and the exact coordinate mapping.

    Why a parameter and not just documentation: it is four settings, and the written version was
    usually half-followed. Measured here:

    capture cost frame handed to the vision model
    default inspect 1075-1150 ms 135 KB PNG, 1920x1080
    purpose: "look" 514 ms 23 KB JPEG, 672x378

    The enumeration alone is half the capture: it is a full UI Automation pass, measured at ~600 ms by
    itself on a Chromium window. Nothing needs it when no click is being aimed.

  • New guards A11 / A12: look must really skip the enumeration and come back as a small JPEG,
    AND it must really be faster than the default. The second is the point — a "fast path" that is not
    measurably faster is just a second name for the same thing.

Also recorded, because it was assumed rather than measured: lowering the subagent reasoning effort
to low did not make a screen read faster (21 s against 20 s at the default effort). A bare
subagent that calls no tool at all already costs 11 s to spawn, so half the round trip is fixed
overhead and the thinking is not the bottleneck. What does work, measured: reusing one warm
subagent
for repeated looks — the second look took 6 s, about 3.3x faster than spawning a fresh
one for each.

v0.3.24 — gestures: the press had no duration, and nothing said so

Choose a tag to compare

@xie129716 xie129716 released this 13 Sep 16:14

Found by re-running the 4399 Gomoku game that defeated an earlier attempt, and then verifying the
whole gesture set against a clipboard instead of by eye.

  • EVERY PRESS WAS INSTANTANEOUS. Merging the three one-shot scripts in 0.3.18 put the button-down
    and the button-up into a single SendInput batch, so the press lasted ~0 ms. The upstream
    script had slept 40 ms between them; that was lost. A zero-length press silently breaks every
    target that measures it: long-press-to-place games, press-and-hold menus, and double-click
    detection that needs two real presses. Restored, as a parameter rather than a constant:
    computer_click takes press_ms (default 50, 0..10000) and computer_drag takes
    hold_ms (how long to hold BEFORE the pointer moves, default 0) — which is also how a
    press-and-hold is expressed at all: pass the same point twice.

  • The duration was applied but never reported, so a caller could not tell a 50 ms click from a
    600 ms one. Both tools now return press_ms / hold_ms. The new guard caught this on its first
    run, which is exactly what it is for.

  • New guard B2c: a press_ms: 600 click must report press_ms=600 AND take at least 600 ms of
    wall clock. A regression here is invisible in the result payload alone.

Gesture set verified objectively — the clipboard is the ground truth, not a look at the screen:

gesture evidence
double-click double-clicking the word bravo in Notepad put bravo on the clipboard; a single click at the same point selected nothing
drag dragging from the start of one line to the end of another put exactly DELTA echo foxtrot on the clipboard
press duration press_ms=50 → 718 ms wall, press_ms=800 → 1537 ms wall

For the record, on the Gomoku game itself: the grid was measured from the screenshot pixels (15
lines each way, 28.5 px pitch, cross-checked against the four star points and the centre stone), and
the long-press-drag then placed stones within 3 px of the intended intersections — against an
earlier run where nine blind clicks placed a single stone. The game's own instruction, 长按拖动落子,
is literally "long-press, then drag"; this was never a missing feature, only a broken press.