things-api 0.19.3 → 0.20.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.
Files changed (139) hide show
  1. package/README.md +1 -1
  2. package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
  3. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/MacOS/things-reader +0 -0
  4. package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
  5. package/dist/audit/schema.d.ts +42 -10
  6. package/dist/audit/schema.js +21 -0
  7. package/dist/audit/schema.js.map +1 -1
  8. package/dist/cli/commands/doctor.js +3 -1
  9. package/dist/cli/commands/doctor.js.map +1 -1
  10. package/dist/cli/commands/op-result.js +25 -7
  11. package/dist/cli/commands/op-result.js.map +1 -1
  12. package/dist/cli/commands/repeat-flags.d.ts +19 -1
  13. package/dist/cli/commands/repeat-flags.js +71 -0
  14. package/dist/cli/commands/repeat-flags.js.map +1 -1
  15. package/dist/cli/commands/rescue.d.ts +12 -0
  16. package/dist/cli/commands/rescue.js +83 -0
  17. package/dist/cli/commands/rescue.js.map +1 -0
  18. package/dist/cli/commands/writes.js +86 -32
  19. package/dist/cli/commands/writes.js.map +1 -1
  20. package/dist/cli/help.js +25 -25
  21. package/dist/cli/help.js.map +1 -1
  22. package/dist/cli/main.js +12 -2
  23. package/dist/cli/main.js.map +1 -1
  24. package/dist/cli/resolve-invocation.d.ts +9 -1
  25. package/dist/cli/resolve-invocation.js +71 -0
  26. package/dist/cli/resolve-invocation.js.map +1 -1
  27. package/dist/cli/unknown-command.d.ts +26 -0
  28. package/dist/cli/unknown-command.js +135 -0
  29. package/dist/cli/unknown-command.js.map +1 -0
  30. package/dist/contracts.d.ts +4 -3
  31. package/dist/contracts.js +1 -1
  32. package/dist/index.d.ts +4 -2
  33. package/dist/index.js +1 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/mcp/server.js +62 -10
  36. package/dist/mcp/server.js.map +1 -1
  37. package/dist/model/entities.d.ts +11 -0
  38. package/dist/model/entities.js.map +1 -1
  39. package/dist/model/mappers.js +15 -0
  40. package/dist/model/mappers.js.map +1 -1
  41. package/dist/op-result.d.ts +24 -2
  42. package/dist/op-result.js +77 -20
  43. package/dist/op-result.js.map +1 -1
  44. package/dist/process-instance.d.ts +41 -0
  45. package/dist/process-instance.js +82 -0
  46. package/dist/process-instance.js.map +1 -0
  47. package/dist/rescue.d.ts +247 -0
  48. package/dist/rescue.js +811 -0
  49. package/dist/rescue.js.map +1 -0
  50. package/dist/session-grant.js +4 -15
  51. package/dist/session-grant.js.map +1 -1
  52. package/dist/ui-state.d.ts +8 -4
  53. package/dist/ui-state.js +8 -4
  54. package/dist/ui-state.js.map +1 -1
  55. package/dist/write/batch.js +4 -1
  56. package/dist/write/batch.js.map +1 -1
  57. package/dist/write/clone.js +12 -6
  58. package/dist/write/clone.js.map +1 -1
  59. package/dist/write/commands.d.ts +9 -0
  60. package/dist/write/commands.js +135 -50
  61. package/dist/write/commands.js.map +1 -1
  62. package/dist/write/disclosures.d.ts +275 -0
  63. package/dist/write/disclosures.js +277 -0
  64. package/dist/write/disclosures.js.map +1 -0
  65. package/dist/write/experimental.d.ts +16 -0
  66. package/dist/write/experimental.js +19 -0
  67. package/dist/write/experimental.js.map +1 -1
  68. package/dist/write/failure-hints.js +3 -2
  69. package/dist/write/failure-hints.js.map +1 -1
  70. package/dist/write/guards.d.ts +1 -1
  71. package/dist/write/guards.js +102 -3
  72. package/dist/write/guards.js.map +1 -1
  73. package/dist/write/heading.js +4 -7
  74. package/dist/write/heading.js.map +1 -1
  75. package/dist/write/lock.d.ts +85 -1
  76. package/dist/write/lock.js +121 -6
  77. package/dist/write/lock.js.map +1 -1
  78. package/dist/write/make-repeating-project.js +2 -1
  79. package/dist/write/make-repeating-project.js.map +1 -1
  80. package/dist/write/operations.d.ts +52 -6
  81. package/dist/write/operations.js +22 -0
  82. package/dist/write/operations.js.map +1 -1
  83. package/dist/write/opid.d.ts +69 -8
  84. package/dist/write/opid.js +116 -20
  85. package/dist/write/opid.js.map +1 -1
  86. package/dist/write/param-schema.js +14 -2
  87. package/dist/write/param-schema.js.map +1 -1
  88. package/dist/write/pipeline.d.ts +82 -3
  89. package/dist/write/pipeline.js +204 -60
  90. package/dist/write/pipeline.js.map +1 -1
  91. package/dist/write/planner.d.ts +7 -0
  92. package/dist/write/planner.js +12 -0
  93. package/dist/write/planner.js.map +1 -1
  94. package/dist/write/preserve-modified.d.ts +17 -8
  95. package/dist/write/preserve-modified.js.map +1 -1
  96. package/dist/write/promote-clone.js +106 -53
  97. package/dist/write/promote-clone.js.map +1 -1
  98. package/dist/write/reorder.js +45 -39
  99. package/dist/write/reorder.js.map +1 -1
  100. package/dist/write/repeat-rule.d.ts +25 -1
  101. package/dist/write/repeat-rule.js +69 -0
  102. package/dist/write/repeat-rule.js.map +1 -1
  103. package/dist/write/resolution-timestamps.js +104 -78
  104. package/dist/write/resolution-timestamps.js.map +1 -1
  105. package/dist/write/reversibility.js +2 -2
  106. package/dist/write/reversibility.js.map +1 -1
  107. package/dist/write/spawn-expectation.d.ts +135 -0
  108. package/dist/write/spawn-expectation.js +237 -0
  109. package/dist/write/spawn-expectation.js.map +1 -0
  110. package/dist/write/template-mutation.js +34 -35
  111. package/dist/write/template-mutation.js.map +1 -1
  112. package/dist/write/undo.js +45 -5
  113. package/dist/write/undo.js.map +1 -1
  114. package/dist/write/vectors/simulator.js +48 -2
  115. package/dist/write/vectors/simulator.js.map +1 -1
  116. package/dist/write/vectors/types.d.ts +12 -0
  117. package/dist/write/vectors/ui-recipes.js +11 -1
  118. package/dist/write/vectors/ui-recipes.js.map +1 -1
  119. package/dist/write/vectors/ui-state.d.ts +64 -6
  120. package/dist/write/vectors/ui-state.js +91 -4
  121. package/dist/write/vectors/ui-state.js.map +1 -1
  122. package/dist/write/vectors/ui.d.ts +69 -3
  123. package/dist/write/vectors/ui.js +293 -146
  124. package/dist/write/vectors/ui.js.map +1 -1
  125. package/dist/write/vectors/url-scheme.js +26 -0
  126. package/dist/write/vectors/url-scheme.js.map +1 -1
  127. package/dist/write/verify/delta.d.ts +17 -2
  128. package/dist/write/verify/delta.js +46 -16
  129. package/dist/write/verify/delta.js.map +1 -1
  130. package/dist/write/verify/poller.d.ts +3 -2
  131. package/dist/write/verify/poller.js +3 -1
  132. package/dist/write/verify/poller.js.map +1 -1
  133. package/package.json +1 -1
  134. package/schema/envelope.schema.json +1 -2
  135. package/skills/things-cli/SKILL.md +4 -2
  136. package/skills/things-cli/references/repeating.md +1 -1
  137. package/dist/cli/commands/ui-state.d.ts +0 -11
  138. package/dist/cli/commands/ui-state.js +0 -27
  139. package/dist/cli/commands/ui-state.js.map +0 -1
@@ -35,37 +35,50 @@ import { createReachabilityCache, H_UI_SESSION_UNREACHABLE, probeSessionReachabi
35
35
  import { certificationOf } from "./ui-certification.js";
36
36
  import { chordCommand, driveHeadingChordReorder } from "./ui-chord.js";
37
37
  import { driveSidebarAreaReorder, jxaSidebarSnapshotScript } from "./ui-drag.js";
38
- import { AX_DIALOG_SHELL_SNIPPET, CENSUS_TIMEOUT_MS, censusUnverifiable, describeFocusOwner, describeUnprovenProbes, readUiState, SYNC_GATE_WARNING, THINGS_PROCESS, } from "./ui-state.js";
38
+ import { AX_DIALOG_SHELL_SNIPPET, axFocusGuardPrelude, CENSUS_TIMEOUT_MS, censusUnverifiable, describeFocusOwner, describeUnprovenProbes, GUARD_REFUSED_TAG, parseGuardLog, readUiState, SYNC_GATE_WARNING, THINGS_PROCESS, } from "./ui-state.js";
39
39
  /** GUI driving can stall on an unanswered sheet; give each step headroom. */
40
40
  const STEP_TIMEOUT_MS = 15_000;
41
41
  /**
42
- * Poll interval while waiting for a dynamic element (sheet/popover). KEPT at 300ms
43
- * after the PERF2 audit: the control a mode switch reveals takes ~462ms to appear
44
- * on the golden (S5b, [docs/lab/perf2-step-latency.md]), which EXCEEDS this
45
- * interval — so a 300ms poll catches it on its second round; a finer interval
46
- * would only add osascript hops for a marginal detection gain (UIC6 confirmed).
47
- */
48
- const WAIT_POLL_MS = 300;
49
- /**
50
- * How long `resolveStepPath` polls a candidate-addressed control before failing
42
+ * How long a candidate-addressed control is polled for before the step fails
51
43
  * closed. The full-vocabulary dialog reveals a pop-up/field a beat AFTER the
52
44
  * frequency/Ends switch that precedes it (UIC6: ~250 ms), so the effective-form
53
- * resolution must poll, not snap once.
45
+ * resolution must poll, not snap once. Since DRVLAT1 (issue #633) that poll runs
46
+ * IN-SCRIPT, inside the hop that acts on the control ({@link axCandidatePrelude}),
47
+ * rather than as its own osascript round-trip per candidate per round.
54
48
  */
55
49
  const RESOLVE_CANDIDATE_TIMEOUT_MS = 5_000;
56
50
  /**
57
- * Settle after the reveal/activate preamble so the menu bar repopulates for the
58
- * newly-selected target before the canary reads it (UIC1: the Items Repeat
59
- * submenu appears only once a repeating item is selected, and the update is not
60
- * instantaneous). TRIMMED 1500 1000 by the PERF2 audit (S5a,
61
- * [docs/lab/perf2-step-latency.md]): on a warm running app under DEFAULT macOS
62
- * animations the menu repopulates in ~92ms median / 116ms max (N=10) — a ~13×
63
- * margin at 1500. Menu-bar repopulation is a LOCAL UI operation (not a DB-commit /
64
- * sync-bound one), so it does not scale with DB size the way the OK commit does;
65
- * 1000ms keeps ~8.6× the golden max as host headroom. Under-margining only ever
66
- * costs a fail-closed spurious drive refusal (the canary miss), never a bad write.
51
+ * In-script poll cadence for the element waits the drive folded into their own
52
+ * hops (DRVLAT1, issue #633). The JS-side poll it replaces was kept at a coarse
53
+ * 300ms deliberately it paid a PROCESS SPAWN per round (PERF2 S5b), so a finer
54
+ * interval bought detection at the price of hops. An in-script poll pays one
55
+ * addressed `exists` per round, so it can be fine enough that a wait ends when
56
+ * the element lands rather than at the next 300ms boundary.
57
+ */
58
+ const IN_SCRIPT_POLL_S = 0.05;
59
+ /**
60
+ * How long the canary and the eligibility assertion poll for the menu bar to
61
+ * repopulate around the newly-selected target (UIC1: the Items ▸ Repeat submenu
62
+ * appears only once a matching item is selected, and the update is not
63
+ * instantaneous).
64
+ *
65
+ * This REPLACES the fixed post-preamble settle (DRVLAT1, issue #633). That settle
66
+ * was 1500ms, trimmed to 1000ms by PERF2 against a measured ~92ms median / 116ms
67
+ * max menu repopulation (S5a) — i.e. it spent ~900ms of every drive waiting out a
68
+ * margin, on every host, whether or not the menu was already there. The closed-loop
69
+ * form is strictly better on both axes: it proceeds the moment the menu answers
70
+ * (the common case, ~one poll), and it tolerates a host slower than any fixed
71
+ * settle would have covered. Under-margining still only ever costs a fail-closed
72
+ * refusal, never a bad write (determinism doctrine; BEEP1 shape-settle precedent).
73
+ */
74
+ const MENU_SETTLE_TIMEOUT_MS = 4_000;
75
+ /**
76
+ * Default window for a click's post-condition wait when the step names none.
77
+ * Every recipe that asserts one sets 5000 explicitly; this only keeps the
78
+ * in-script poll comfortably INSIDE the hop's own {@link STEP_TIMEOUT_MS}, which
79
+ * a 15s default would not (the poll would outlive its transport).
67
80
  */
68
- const SETTLE_AFTER_REVEAL_MS = 1000;
81
+ const WAIT_ASSERT_TIMEOUT_MS = 5_000;
69
82
  /**
70
83
  * A shape-dependent step reached without the dialog having been measured — a
71
84
  * recipe bug (the `probe-dialog-shape` step is missing or ran after its
@@ -299,6 +312,82 @@ end fgAssertFront`;
299
312
  export function axResolveScript(path) {
300
313
  return `${SE} to return (exists (${path}))`;
301
314
  }
315
+ /**
316
+ * The AppleScript variable a folded candidate resolution binds the live element
317
+ * to. Every addressed script takes its target as `(<path>)`, so handing it this
318
+ * name — with {@link axCandidatePrelude} in front — makes the resolution and the
319
+ * action ONE hop instead of two (DRVLAT1, issue #633).
320
+ */
321
+ export const STEP_ELEMENT_REF = "fgStepEl";
322
+ /**
323
+ * The message a folded candidate resolution raises when NONE of a step's expected
324
+ * element shapes appeared. Byte-identical to the driver's own wording for the
325
+ * separate-hop resolution it replaces, so a report reads the same either way.
326
+ */
327
+ export const CANDIDATES_MISSED = "none of its expected element shapes resolved (neither the attached sheet nor the " +
328
+ "detached repeat editor window)";
329
+ /**
330
+ * IN-SCRIPT CANDIDATE RESOLUTION (DRVLAT1, issue #633).
331
+ *
332
+ * A candidate-addressed step used to dispatch one `resolve` hop PER CANDIDATE PER
333
+ * POLL ROUND before the hop that acted on whichever answered — a process spawn
334
+ * each, on top of a 300ms JS-side poll floor. This prelude does the same work in
335
+ * the acting hop's own process: it polls the candidates in the SAME priority
336
+ * order, binds the first that exists to {@link STEP_ELEMENT_REF}, and fails closed
337
+ * with {@link CANDIDATES_MISSED} when the window elapses with none of them there.
338
+ *
339
+ * Collapsing it is also strictly better against TOCTOU: the element the script
340
+ * acts on is the one it just proved exists, with nothing dispatched in between.
341
+ */
342
+ export function axCandidatePrelude(paths, timeoutMs = RESOLVE_CANDIDATE_TIMEOUT_MS) {
343
+ const probes = paths
344
+ .map((p) => ` try
345
+ if (exists (${p})) then set ${STEP_ELEMENT_REF} to (${p})
346
+ end try
347
+ if ${STEP_ELEMENT_REF} is not missing value then exit repeat`)
348
+ .join("\n");
349
+ return `set ${STEP_ELEMENT_REF} to missing value
350
+ set fgT0 to (current date)
351
+ ${SE}
352
+ repeat
353
+ ${probes}
354
+ if ((current date) - fgT0) is greater than or equal to ${pollSeconds(timeoutMs)} then exit repeat
355
+ delay ${IN_SCRIPT_POLL_S}
356
+ end repeat
357
+ end tell
358
+ if ${STEP_ELEMENT_REF} is missing value then error "${escapeAppleScript(CANDIDATES_MISSED)}"`;
359
+ }
360
+ /**
361
+ * wait: poll for ANY of the awaited element shapes to appear, IN-SCRIPT (DRVLAT1).
362
+ * One hop for the whole wait instead of one per candidate per round — which is
363
+ * what a slow host paid most: the dialog the drive is waiting for is exactly the
364
+ * thing that is slow when the app is busy. Returns "true", or "false" when the
365
+ * window elapses with none of them present (the driver's abort path is unchanged).
366
+ */
367
+ export function axWaitAnyScript(paths, timeoutMs) {
368
+ const probes = paths
369
+ .map((p) => ` try
370
+ if (exists (${p})) then return "true"
371
+ end try`)
372
+ .join("\n");
373
+ return `set fgT0 to (current date)
374
+ ${SE}
375
+ repeat
376
+ ${probes}
377
+ if ((current date) - fgT0) is greater than or equal to ${pollSeconds(timeoutMs)} then return "false"
378
+ delay ${IN_SCRIPT_POLL_S}
379
+ end repeat
380
+ end tell`;
381
+ }
382
+ /**
383
+ * A poll window in whole seconds, as the in-script `current date` deadline reads
384
+ * it (AppleScript dates carry second granularity). Never below 1: a sub-second
385
+ * window would make a loop that checks its deadline after the FIRST probe into a
386
+ * single-shot check, which is the race these loops exist to close.
387
+ */
388
+ function pollSeconds(timeoutMs) {
389
+ return Math.max(1, Math.round(timeoutMs / 1000));
390
+ }
302
391
  /** press: AXPress the element. */
303
392
  export function axPressScript(path) {
304
393
  return `${SE} to click (${path})`;
@@ -821,12 +910,20 @@ export function axSelectPopupScript(path, value) {
821
910
  */
822
911
  export function axSelectPopupCandidatesScript(path, values) {
823
912
  const list = values.map((v) => `"${escapeAppleScript(v)}"`).join(", ");
913
+ // The menu is WAITED FOR, not slept on (DRVLAT1, issue #633): the old loop paid
914
+ // a flat 0.3s after every click before it would look again, so the common case —
915
+ // one click, menu up in well under that — spent the remainder of the settle
916
+ // doing nothing. The click cadence is unchanged (one click per round, never a
917
+ // second click into a menu that is opening — BEEP1); only the looking is finer.
824
918
  return `${SE}
825
919
  set pu to (${path})
826
920
  repeat 20 times
827
921
  if (exists menu 1 of pu) then exit repeat
828
922
  click pu
829
- delay 0.3
923
+ repeat 6 times
924
+ if (exists menu 1 of pu) then exit repeat
925
+ delay ${IN_SCRIPT_POLL_S}
926
+ end repeat
830
927
  end repeat
831
928
  repeat with candidate in {${list}}
832
929
  if (exists menu item candidate of menu 1 of pu) then
@@ -1257,26 +1354,37 @@ return "NOMATCH"`;
1257
1354
  * naming expected vs observed. Pure System Events + Things scripting, background-
1258
1355
  * capable. One stable command shape per primitive.
1259
1356
  */
1260
- export function axAssertEligibleScript(targetUuid, menuItemPath) {
1357
+ export function axAssertEligibleScript(targetUuid, menuItemPath, settleMs = MENU_SETTLE_TIMEOUT_MS) {
1261
1358
  const u = escapeAppleScript(targetUuid);
1262
- return `set selIds to {}
1263
- tell application "Things3"
1264
- try
1265
- set selIds to id of selected to dos
1266
- end try
1267
- end tell
1268
- if (count of selIds) is 0 then return "NOTSEL no to-do is selected after the reveal (expected ${u}) — the show URL navigated without selecting an eligible row"
1269
- if (count of selIds) is greater than 1 then return "NOTSEL " & (count of selIds) & " to-dos are selected, expected exactly the target ${u}"
1270
- set theId to (item 1 of selIds) as text
1271
- if theId is not "${u}" then return "WRONGSEL the selected to-do is " & theId & ", expected the target ${u}"
1272
- set repEnabled to false
1273
- tell application "System Events" to tell process "Things3"
1274
- try
1275
- set repEnabled to enabled of ${menuItemPath}
1276
- end try
1277
- end tell
1278
- if repEnabled is false then return "DISABLED the target ${u} is selected but its Repeat menu item is disabled (not an eligible row for this action)"
1279
- return "OK"`;
1359
+ return `set t0 to (current date)
1360
+ set verdict to my aeCheck()
1361
+ repeat until verdict is "OK"
1362
+ if ((current date) - t0) is greater than or equal to ${pollSeconds(settleMs)} then exit repeat
1363
+ delay ${IN_SCRIPT_POLL_S}
1364
+ set verdict to my aeCheck()
1365
+ end repeat
1366
+ return verdict
1367
+
1368
+ on aeCheck()
1369
+ set selIds to {}
1370
+ tell application "Things3"
1371
+ try
1372
+ set selIds to id of selected to dos
1373
+ end try
1374
+ end tell
1375
+ if (count of selIds) is 0 then return "NOTSEL no to-do is selected after the reveal (expected ${u}) the show URL navigated without selecting an eligible row"
1376
+ if (count of selIds) is greater than 1 then return "NOTSEL " & (count of selIds) & " to-dos are selected, expected exactly the target ${u}"
1377
+ set theId to (item 1 of selIds) as text
1378
+ if theId is not "${u}" then return "WRONGSEL the selected to-do is " & theId & ", expected the target ${u}"
1379
+ set repEnabled to false
1380
+ tell application "System Events" to tell process "Things3"
1381
+ try
1382
+ set repEnabled to enabled of ${menuItemPath}
1383
+ end try
1384
+ end tell
1385
+ if repEnabled is false then return "DISABLED the target ${u} is selected but its Repeat menu item is disabled (not an eligible row for this action)"
1386
+ return "OK"
1387
+ end aeCheck`;
1280
1388
  }
1281
1389
  /** activate: foreground Things (the fallback preamble step). */
1282
1390
  export function axActivateScript() {
@@ -1613,7 +1721,7 @@ async function clickCancel(run) {
1613
1721
  const center = parseFrameCenter(frameRes.stdout);
1614
1722
  if (center === null)
1615
1723
  return false;
1616
- const res = await run(clickPointCommand(center.x, center.y, "click the open dialog's Cancel button"), STEP_TIMEOUT_MS);
1724
+ const res = await run(uiClickPointCommand(center.x, center.y, "click the open dialog's Cancel button"), STEP_TIMEOUT_MS);
1617
1725
  return res.ok;
1618
1726
  }
1619
1727
  /**
@@ -1872,7 +1980,12 @@ $.CGEventPost($.kCGHIDEventTap, mev(1)); sleep(15);
1872
1980
  $.CGEventPost($.kCGHIDEventTap, mev(2));`;
1873
1981
  }
1874
1982
  /** The command that posts an AX-resolved mouse click (one stable JXA shape). */
1875
- function clickPointCommand(x, y, label) {
1983
+ /**
1984
+ * A synthesized HID click at a screen point. Exported (issue #640) so the rescue
1985
+ * path's Cancel fallback and the drive's own click are literally the same shape
1986
+ * — a second construction site is a second place for the event sequence to drift.
1987
+ */
1988
+ export function uiClickPointCommand(x, y, label) {
1876
1989
  return { primitive: "click-point", label, lang: "javascript", script: jxaClickScript(x, y) };
1877
1990
  }
1878
1991
  /**
@@ -2060,13 +2173,28 @@ async function defaultRun(command, timeoutMs) {
2060
2173
  }
2061
2174
  /**
2062
2175
  * Read the live window/focus census through the shipped dispatch seam — the
2063
- * transport behind the `ui-state` diagnostic (src/ui-state.ts) and, in tests,
2176
+ * transport behind the window-state diagnostic (src/ui-state.ts) and, in tests,
2064
2177
  * behind any injected runner. READ-ONLY: the census clicks nothing, types
2065
2178
  * nothing, and changes no state; see src/write/vectors/ui-state.ts.
2066
2179
  */
2067
2180
  export function readLiveUiState(run = defaultRun) {
2068
2181
  return readUiState(run, CENSUS_TIMEOUT_MS);
2069
2182
  }
2183
+ /**
2184
+ * The production dispatch seam itself, exported (issue #640).
2185
+ *
2186
+ * The drive owns its own runner and always will. But `things rescue` presses a
2187
+ * button on a dialog no drive opened, so it needs the SAME transport — the
2188
+ * deputy routing, the reveal/osascript split, the timeout handling — without
2189
+ * borrowing the drive's recipe machinery. Re-declaring a second dispatcher would
2190
+ * mean a second place for the deputy contract to drift, which is the one thing
2191
+ * this seam exists to prevent.
2192
+ *
2193
+ * Nothing else changes: this is `defaultRun` under a name a caller outside the
2194
+ * vector can read, and every injected-runner test seam still works exactly as
2195
+ * before.
2196
+ */
2197
+ export const defaultUiRunner = defaultRun;
2070
2198
  /**
2071
2199
  * Wrap the dispatch seam so every osascript hop is recorded. The last-dispatched
2072
2200
  * step is noted on the in-flight-write marker (so a SIGTERM/SIGINT can name it,
@@ -2186,24 +2314,32 @@ export function judgeFocusGuard(state, expectedSheet, label) {
2186
2314
  * state, taken immediately before the hop, and the in-script assertions close
2187
2315
  * the remaining milliseconds (UI-automation determinism doctrine; the #595
2188
2316
  * pre-commit audit and BEEP1 shape-settle are the same pattern).
2317
+ *
2318
+ * FOLDED for keystroke-class hops (DRVLAT1, issue #633). Those hops are
2319
+ * AppleScript, so the census can be — and now is — the PRELUDE OF THE VERY SCRIPT
2320
+ * THAT TYPES ({@link axFocusGuardPrelude}) rather than a hop of its own. Two
2321
+ * things improve at once: the drive stops paying a process spawn per typed
2322
+ * control, and the TOCTOU window between "the census approved this" and "the
2323
+ * keystroke went out" closes to nothing — no dispatch happens in between, because
2324
+ * there is nothing in between. The judgement is still made BEFORE the keystroke
2325
+ * (in-script, fail-closed) and the sentence a caller reads is still built HERE by
2326
+ * {@link judgeFocusGuard}, from the census that same hop logged, so there remains
2327
+ * exactly one wording of every refusal.
2328
+ *
2329
+ * POINTER-class hops keep the separate census: they dispatch JXA, which cannot
2330
+ * carry an AppleScript prelude.
2189
2331
  */
2190
2332
  function guardedRun(inner, latch) {
2191
2333
  return async (command, timeoutMs) => {
2192
- if (!KEYSTROKE_CLASS.has(command.primitive) && !POINTER_CLASS.has(command.primitive)) {
2334
+ const keystroke = KEYSTROKE_CLASS.has(command.primitive);
2335
+ if (!keystroke && !POINTER_CLASS.has(command.primitive)) {
2193
2336
  return inner(command, timeoutMs);
2194
2337
  }
2195
- const state = await readUiState(inner, CENSUS_TIMEOUT_MS);
2196
- // An inspection that would not answer poisons every later inspection's
2197
- // credibility, so the cleanup ladder is told once and never asks again
2198
- // (issue #629).
2199
- if (state === null || censusUnverifiable(state))
2200
- latch.inspectionStalled = true;
2201
2338
  // The dialog invariant applies to keystroke-class hops only: a pointer hop
2202
2339
  // is aimed at a frame it resolved a moment ago and fails closed on its own
2203
2340
  // if that frame moved.
2204
- const expected = KEYSTROKE_CLASS.has(command.primitive) ? latch.sheet : null;
2205
- const guardRefusal = judgeFocusGuard(state, expected, command.label);
2206
- if (guardRefusal !== null) {
2341
+ const expected = keystroke ? latch.sheet : null;
2342
+ const refuse = (state, why) => {
2207
2343
  trace(() => ({
2208
2344
  phase: "focus-guard",
2209
2345
  event: "refused",
@@ -2214,10 +2350,41 @@ function guardedRun(inner, latch) {
2214
2350
  inspectable: state?.inspectable ?? false,
2215
2351
  stalled: state?.stalledProbes ?? null,
2216
2352
  }));
2217
- return { ok: false, stdout: "", stderr: guardRefusal };
2353
+ return { ok: false, stdout: "", stderr: why };
2354
+ };
2355
+ // An inspection that would not answer poisons every later inspection's
2356
+ // credibility, so the cleanup ladder is told once and never asks again
2357
+ // (issue #629).
2358
+ const noteStall = (state) => {
2359
+ if (state === null || censusUnverifiable(state))
2360
+ latch.inspectionStalled = true;
2361
+ };
2362
+ // Latched only on a census that APPROVED the hop — a dialog seen while
2363
+ // refusing is, by construction, not the one this drive is driving.
2364
+ const latchSheet = (state) => {
2365
+ if (state !== null && state.sheetOpen && latch.sheet === null)
2366
+ latch.sheet = state.sheetKind;
2367
+ };
2368
+ if (keystroke && command.lang !== "javascript" && typeof command.script === "string") {
2369
+ const res = await inner({ ...command, script: `${axFocusGuardPrelude(expected)}\n${command.script}` }, timeoutMs);
2370
+ const { state, stderr } = parseGuardLog(res.stderr);
2371
+ noteStall(state);
2372
+ if (!res.ok && stderr.includes(GUARD_REFUSED_TAG)) {
2373
+ // The script refused. Re-judge the census it logged for the sentence; a
2374
+ // census too damaged to re-judge still refuses — the hop already did.
2375
+ return refuse(state, judgeFocusGuard(state, expected, command.label) ??
2376
+ `refused to run "${command.label}": the window and focus state could not be read, so ` +
2377
+ "there is no proof the input would reach Things — nothing was sent");
2378
+ }
2379
+ latchSheet(state);
2380
+ return { ...res, stderr };
2218
2381
  }
2219
- if (state !== null && state.sheetOpen && latch.sheet === null)
2220
- latch.sheet = state.sheetKind;
2382
+ const state = await readUiState(inner, CENSUS_TIMEOUT_MS);
2383
+ noteStall(state);
2384
+ const guardRefusal = judgeFocusGuard(state, expected, command.label);
2385
+ if (guardRefusal !== null)
2386
+ return refuse(state, guardRefusal);
2387
+ latchSheet(state);
2221
2388
  return inner(command, timeoutMs);
2222
2389
  };
2223
2390
  }
@@ -2295,40 +2462,43 @@ function canaryPaths(recipe) {
2295
2462
  }
2296
2463
  return out;
2297
2464
  }
2298
- /**
2299
- * Resolve a step's effective element path. A `pathCandidates` step dispatches
2300
- * against the FIRST candidate that exists (the dialog-form disjunction — attached
2301
- * sheet vs detached AXUnknown window, UIC4-a). The candidates are POLLED over a
2302
- * bounded window because the full-vocabulary controls are REVEALED by the
2303
- * preceding step: switching the frequency pop-up to weekly/monthly/yearly (or
2304
- * ticking Ends=after) re-lays-out the cadence group, and the new pop-up/field
2305
- * lands ~250 ms later (UIC6). A single immediate exists-check races that render
2306
- * and would spuriously fail closed; polling matches the `dynamic` nature these
2307
- * steps already declare. Returns null when none resolve within the window.
2308
- */
2309
- async function resolveStepPath(step, run) {
2310
- if (step.pathCandidates === undefined)
2311
- return step.path ?? null;
2312
- const candidates = step.pathCandidates;
2313
- const deadline = Date.now() + (step.timeoutMs ?? RESOLVE_CANDIDATE_TIMEOUT_MS);
2314
- for (;;) {
2315
- for (const candidate of candidates) {
2316
- // candidates are tried in priority order; the first hit wins, so a race would blur which form matched
2317
- const res = await run({ primitive: "resolve", label: step.label, script: axResolveScript(candidate) }, STEP_TIMEOUT_MS);
2318
- if (res.ok && res.stdout.trim() === "true")
2319
- return candidate;
2320
- }
2321
- if (Date.now() >= deadline)
2322
- return null;
2323
- // the revealed control lands a beat after the mode switch; poll until it does
2324
- await new Promise((r) => setTimeout(r, WAIT_POLL_MS));
2325
- }
2326
- }
2327
2465
  function refusal(detail) {
2328
2466
  return { exitCode: 1, stdout: "", stderr: detail };
2329
2467
  }
2330
- /** Compile one recipe step into its primitive command (no dispatch). */
2468
+ /**
2469
+ * Compile one recipe step into its primitive command (no dispatch).
2470
+ *
2471
+ * A CANDIDATE-ADDRESSED step compiles to ONE script that resolves its own element
2472
+ * and then acts on it (DRVLAT1, issue #633): the addressed body is generated
2473
+ * against {@link STEP_ELEMENT_REF} and {@link axCandidatePrelude} is prepended, so
2474
+ * the resolution that used to be a separate `resolve` hop (or several) now rides
2475
+ * the acting hop. Steps whose script is JXA, or that resolve their own target,
2476
+ * are unaffected.
2477
+ */
2331
2478
  export function commandForStep(step, targetUuid) {
2479
+ if (step.primitive === "wait") {
2480
+ // The whole wait is ONE hop: the candidates are polled in-script until one of
2481
+ // them exists or the step's own window elapses (DRVLAT1).
2482
+ const paths = step.pathCandidates ?? [step.path ?? ""];
2483
+ return {
2484
+ primitive: "wait",
2485
+ label: step.label,
2486
+ script: axWaitAnyScript(paths, step.timeoutMs ?? STEP_TIMEOUT_MS),
2487
+ };
2488
+ }
2489
+ if (step.pathCandidates !== undefined && step.path === undefined) {
2490
+ const candidates = step.pathCandidates;
2491
+ const inner = commandForStep({ ...step, path: STEP_ELEMENT_REF }, targetUuid);
2492
+ // Only an AppleScript body can take the AppleScript prelude; a JXA step
2493
+ // (set-datetime, the pointer primitives) resolves its own target anyway.
2494
+ if (inner.lang === "javascript" || typeof inner.script !== "string" || inner.script === "") {
2495
+ return inner;
2496
+ }
2497
+ return {
2498
+ ...inner,
2499
+ script: `${axCandidatePrelude(candidates, RESOLVE_CANDIDATE_TIMEOUT_MS)}\n${inner.script}`,
2500
+ };
2501
+ }
2332
2502
  switch (step.primitive) {
2333
2503
  case "reveal":
2334
2504
  return { primitive: "reveal", label: step.label, url: revealUrl(step.value ?? targetUuid) };
@@ -2418,8 +2588,6 @@ export function commandForStep(step, targetUuid) {
2418
2588
  // shape-selected group pop-up index of the first weekday row (RDLG2).
2419
2589
  script: axConvergeWeekdaysScript(step.path ?? "", weekdayBaseOf(step.value ?? ""), weekdayTitlesOf(step.value ?? "")),
2420
2590
  };
2421
- case "wait":
2422
- return { primitive: "wait", label: step.label, script: axResolveScript(step.path ?? "") };
2423
2591
  case "select-row":
2424
2592
  return {
2425
2593
  primitive: "select-row",
@@ -2524,7 +2692,7 @@ async function driveClickElement(step, run) {
2524
2692
  "or the app is not in the expected state; no click was sent",
2525
2693
  };
2526
2694
  }
2527
- const clickRes = await run(clickPointCommand(center.x, center.y, step.label), STEP_TIMEOUT_MS);
2695
+ const clickRes = await run(uiClickPointCommand(center.x, center.y, step.label), STEP_TIMEOUT_MS);
2528
2696
  if (!clickRes.ok) {
2529
2697
  return {
2530
2698
  ok: false,
@@ -2535,12 +2703,13 @@ async function driveClickElement(step, run) {
2535
2703
  };
2536
2704
  }
2537
2705
  if (step.assertPath !== undefined) {
2538
- const ok = await waitForElement({
2706
+ // one hop, polled in-script (DRVLAT1)
2707
+ const res = await run({
2539
2708
  primitive: "wait",
2540
2709
  label: step.assertLabel ?? step.label,
2541
- script: axResolveScript(step.assertPath),
2542
- }, step.assertTimeoutMs ?? STEP_TIMEOUT_MS, run);
2543
- if (!ok) {
2710
+ script: axWaitAnyScript([step.assertPath], step.assertTimeoutMs ?? WAIT_ASSERT_TIMEOUT_MS),
2711
+ }, STEP_TIMEOUT_MS);
2712
+ if (!(res.ok && res.stdout.trim() === "true")) {
2544
2713
  return {
2545
2714
  ok: false,
2546
2715
  why: `${step.assertLabel ?? "the expected element"} did not appear after the click`,
@@ -2705,6 +2874,7 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
2705
2874
  exitCode: 1,
2706
2875
  stdout: `ui drive watchdog stopped after ${done.length} step(s): ${done.join(" → ") || "nothing"}`,
2707
2876
  stderr: `ui drive exceeded its ${Math.round(budgetMs / 1000)}s budget at "${lastStep}"`,
2877
+ steps: [...done],
2708
2878
  timedOut: true,
2709
2879
  watchdog: {
2710
2880
  budgetMs,
@@ -2738,7 +2908,11 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
2738
2908
  // reachable to be driven — which is not the app accepting a command and
2739
2909
  // changing nothing, and must not be reported as that.
2740
2910
  const cause = clear?.state === "cleared-blind" ? "unreachable" : stepTimedOut ? "unresponsive" : null;
2741
- const res = refusal(base + cleanup);
2911
+ // The step list rides EVERY partial exit: a failure always carries the
2912
+ // play-by-play, which is what made the field bug reports rich (#632). The
2913
+ // step that stopped the drive is named as the last entry so the list reads
2914
+ // as the whole attempt, not only the part that worked.
2915
+ const res = { ...refusal(base + cleanup), steps: [...done, `${failed} — FAILED: ${why}`] };
2742
2916
  if (cause === null)
2743
2917
  return res;
2744
2918
  return {
@@ -2772,9 +2946,9 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
2772
2946
  done.push(step.label);
2773
2947
  idx += 1;
2774
2948
  }
2775
- // Let the selection settle so the menu bar repopulates before the canary reads it.
2776
- if (idx > 0)
2777
- await new Promise((r) => setTimeout(r, SETTLE_AFTER_REVEAL_MS));
2949
+ // The menu bar repopulates around the new selection a beat after the preamble
2950
+ // (UIC1). That beat is WAITED OUT IN THE CANARY below, which polls each element
2951
+ // it must resolve — no fixed settle stands here any more (DRVLAT1, issue #633).
2778
2952
  // 0½. Session-reachability GATE for dialog-class ops (SESSGATE, #480). A recipe
2779
2953
  // that opens a sheet on the main window needs that window AX-reachable on
2780
2954
  // the current Space. Probed AFTER the preamble (which surfaces a window in a
@@ -2816,7 +2990,13 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
2816
2990
  // pressed. (This is also the localization check: English titles must resolve.)
2817
2991
  for (const { path, label } of canaryPaths(recipe)) {
2818
2992
  // the canary resolves elements one at a time; a single miss aborts before anything is pressed, so parallelizing would waste work and blur which element failed
2819
- const res = await run({ primitive: "resolve", label, script: axResolveScript(path) }, STEP_TIMEOUT_MS);
2993
+ // POLLED, not snapped: this is where the drive waits out the menu-bar
2994
+ // repopulation the preamble triggered (DRVLAT1 — it replaces the fixed settle).
2995
+ const res = await run({
2996
+ primitive: "resolve",
2997
+ label,
2998
+ script: axWaitAnyScript([path], MENU_SETTLE_TIMEOUT_MS),
2999
+ }, STEP_TIMEOUT_MS);
2820
3000
  if (!res.ok || res.stdout.trim() !== "true") {
2821
3001
  // (e) A leftover modal sheet/popover from an earlier aborted drive disables
2822
3002
  // the menu bar, so the Items ▸ Repeat path cannot resolve. Detect that
@@ -2877,9 +3057,11 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
2877
3057
  return watchdogResult(step.label);
2878
3058
  if (step.primitive === "wait") {
2879
3059
  // A candidate-addressed wait polls for ANY of its shapes to appear (the
2880
- // dialog opening as an attached sheet OR a detached AXUnknown window).
3060
+ // dialog opening as an attached sheet OR a detached AXUnknown window) — the
3061
+ // whole poll inside ONE hop (DRVLAT1).
2881
3062
  // steps are strictly sequential: this wait must resolve before the step that acts on the awaited element runs
2882
- const ok = await waitForAnyElement(step.pathCandidates ?? [step.path ?? ""], step.label, step.timeoutMs ?? STEP_TIMEOUT_MS, run);
3063
+ const res = await run(commandForStep(step, recipe.targetUuid), STEP_TIMEOUT_MS);
3064
+ const ok = res.ok && res.stdout.trim() === "true";
2883
3065
  if (!ok) {
2884
3066
  // the abort keystroke must land (and be verified) before returning the partial-state report
2885
3067
  const clear = await clearNow();
@@ -2917,19 +3099,11 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
2917
3099
  done.push(`${step.label} (${outcome.detail})`);
2918
3100
  continue;
2919
3101
  }
2920
- // Resolve a candidate-addressed step's effective element before dispatch
2921
- // (the sheet-vs-detached-window disjunction). A miss fails closed.
2922
- if (step.pathCandidates !== undefined) {
2923
- // the effective form must be resolved before this step can act on it
2924
- const effective = await resolveStepPath(step, run);
2925
- if (effective === null) {
2926
- // dismiss whatever opened (and verify) before reporting
2927
- const clear = await clearNow();
2928
- return partial(step.label, "none of its expected element shapes resolved (neither the attached sheet nor the " +
2929
- "detached repeat editor window)", clear);
2930
- }
2931
- step = { ...step, path: effective };
2932
- }
3102
+ // A candidate-addressed step resolves its effective element (the
3103
+ // sheet-vs-detached-window disjunction) INSIDE its own script now — see
3104
+ // commandForStep / axCandidatePrelude. A miss raises CANDIDATES_MISSED there
3105
+ // and lands on this step's ordinary failure path, with the same wording and
3106
+ // the same clean abort it had when the resolution was its own hop (DRVLAT1).
2933
3107
  const command = commandForStep(step, recipe.targetUuid);
2934
3108
  if (step.primitive === "probe-dialog-shape") {
2935
3109
  // MEASURE the dialog (RDLG2) before any shape-dependent control is touched.
@@ -3045,39 +3219,12 @@ async function drive(recipe, rawRun, aux, budgetMs = DEFAULT_UI_DRIVE_BUDGET_MS,
3045
3219
  exitCode: 0,
3046
3220
  stdout: `${relocationNote}drove ${done.length} step(s): ${done.join(" → ")}`,
3047
3221
  stderr: "",
3222
+ // The same play-by-play as a LIST (#632). `stdout` keeps the prose form the
3223
+ // trace and the transport-failure paths already read; `steps` is what the
3224
+ // change-history record stores and `--verbose` renders.
3225
+ steps: relocationNote === "" ? [...done] : [relocationNote.trim(), ...done],
3048
3226
  };
3049
3227
  }
3050
- async function waitForElement(command, timeoutMs, run) {
3051
- const deadline = Date.now() + timeoutMs;
3052
- for (;;) {
3053
- // polling the same element until it appears is inherently sequential
3054
- const res = await run(command, STEP_TIMEOUT_MS);
3055
- if (res.ok && res.stdout.trim() === "true")
3056
- return true;
3057
- if (Date.now() >= deadline)
3058
- return false;
3059
- // inter-poll delay between sequential existence checks
3060
- await new Promise((r) => setTimeout(r, WAIT_POLL_MS));
3061
- }
3062
- }
3063
- /** Poll until ANY of the candidate element shapes exists (the sheet-vs-detached-window disjunction). */
3064
- async function waitForAnyElement(paths, label, timeoutMs, run) {
3065
- const deadline = Date.now() + timeoutMs;
3066
- for (;;) {
3067
- for (const path of paths) {
3068
- // Emitted as the `wait` primitive (not `resolve`) so the command stream a
3069
- // caller observes is unchanged from the single-path waitForElement.
3070
- // candidates checked in priority order; the first present shape ends the wait
3071
- const res = await run({ primitive: "wait", label, script: axResolveScript(path) }, STEP_TIMEOUT_MS);
3072
- if (res.ok && res.stdout.trim() === "true")
3073
- return true;
3074
- }
3075
- if (Date.now() >= deadline)
3076
- return false;
3077
- // inter-poll delay between sequential existence checks
3078
- await new Promise((r) => setTimeout(r, WAIT_POLL_MS));
3079
- }
3080
- }
3081
3228
  function enabledMatrix() {
3082
3229
  const matrix = {};
3083
3230
  for (const op of UI_DRIVE_OPS) {