things-api 0.19.4 → 0.20.1

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 (131) 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 +28 -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 +15 -9
  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 +35 -25
  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 +55 -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 +26 -9
  40. package/dist/model/mappers.js.map +1 -1
  41. package/dist/model/today-placement.d.ts +65 -0
  42. package/dist/model/today-placement.js +59 -0
  43. package/dist/model/today-placement.js.map +1 -0
  44. package/dist/op-result.d.ts +15 -2
  45. package/dist/op-result.js +72 -20
  46. package/dist/op-result.js.map +1 -1
  47. package/dist/process-instance.d.ts +41 -0
  48. package/dist/process-instance.js +82 -0
  49. package/dist/process-instance.js.map +1 -0
  50. package/dist/rescue.d.ts +247 -0
  51. package/dist/rescue.js +811 -0
  52. package/dist/rescue.js.map +1 -0
  53. package/dist/session-grant.js +4 -15
  54. package/dist/session-grant.js.map +1 -1
  55. package/dist/ui-state.d.ts +8 -4
  56. package/dist/ui-state.js +8 -4
  57. package/dist/ui-state.js.map +1 -1
  58. package/dist/write/commands.d.ts +9 -0
  59. package/dist/write/commands.js +138 -51
  60. package/dist/write/commands.js.map +1 -1
  61. package/dist/write/disclosures.d.ts +24 -0
  62. package/dist/write/disclosures.js +24 -0
  63. package/dist/write/disclosures.js.map +1 -1
  64. package/dist/write/experimental.d.ts +16 -0
  65. package/dist/write/experimental.js +19 -0
  66. package/dist/write/experimental.js.map +1 -1
  67. package/dist/write/failure-hints.js +3 -2
  68. package/dist/write/failure-hints.js.map +1 -1
  69. package/dist/write/guards.d.ts +1 -1
  70. package/dist/write/guards.js +130 -8
  71. package/dist/write/guards.js.map +1 -1
  72. package/dist/write/lock.d.ts +85 -1
  73. package/dist/write/lock.js +121 -6
  74. package/dist/write/lock.js.map +1 -1
  75. package/dist/write/move.js +33 -6
  76. package/dist/write/move.js.map +1 -1
  77. package/dist/write/operations.d.ts +52 -6
  78. package/dist/write/operations.js +22 -0
  79. package/dist/write/operations.js.map +1 -1
  80. package/dist/write/opid.d.ts +69 -8
  81. package/dist/write/opid.js +111 -17
  82. package/dist/write/opid.js.map +1 -1
  83. package/dist/write/param-schema.js +14 -2
  84. package/dist/write/param-schema.js.map +1 -1
  85. package/dist/write/pipeline.d.ts +43 -2
  86. package/dist/write/pipeline.js +206 -43
  87. package/dist/write/pipeline.js.map +1 -1
  88. package/dist/write/planner.d.ts +7 -0
  89. package/dist/write/planner.js +12 -0
  90. package/dist/write/planner.js.map +1 -1
  91. package/dist/write/pre-state.d.ts +9 -9
  92. package/dist/write/pre-state.js +44 -38
  93. package/dist/write/pre-state.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 +60 -17
  97. package/dist/write/promote-clone.js.map +1 -1
  98. package/dist/write/reorder.js +28 -7
  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 +103 -80
  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/template-mutation.js +14 -2
  108. package/dist/write/template-mutation.js.map +1 -1
  109. package/dist/write/undo.js +45 -5
  110. package/dist/write/undo.js.map +1 -1
  111. package/dist/write/vectors/simulator.js +48 -2
  112. package/dist/write/vectors/simulator.js.map +1 -1
  113. package/dist/write/vectors/types.d.ts +10 -0
  114. package/dist/write/vectors/ui-drag.d.ts +102 -0
  115. package/dist/write/vectors/ui-drag.js +425 -11
  116. package/dist/write/vectors/ui-drag.js.map +1 -1
  117. package/dist/write/vectors/ui-state.d.ts +22 -6
  118. package/dist/write/vectors/ui-state.js +1 -1
  119. package/dist/write/vectors/ui-state.js.map +1 -1
  120. package/dist/write/vectors/ui.d.ts +29 -1
  121. package/dist/write/vectors/ui.js +57 -5
  122. package/dist/write/vectors/ui.js.map +1 -1
  123. package/dist/write/vectors/url-scheme.js +26 -0
  124. package/dist/write/vectors/url-scheme.js.map +1 -1
  125. package/package.json +1 -1
  126. package/schema/envelope.schema.json +1 -2
  127. package/skills/things-cli/SKILL.md +4 -2
  128. package/skills/things-cli/references/repeating.md +1 -1
  129. package/dist/cli/commands/ui-state.d.ts +0 -11
  130. package/dist/cli/commands/ui-state.js +0 -27
  131. package/dist/cli/commands/ui-state.js.map +0 -1
package/dist/rescue.js ADDED
@@ -0,0 +1,811 @@
1
+ /**
2
+ * `things rescue` — the headless emergency surface for un-sticking Things
3
+ * (issue #640).
4
+ *
5
+ * WHY THIS EXISTS. Things can reach a state where the app is up, answering, and
6
+ * unusable to automation, with no scripted way back. Three measured behaviours
7
+ * converge on it:
8
+ *
9
+ * - a standing modal sheet EMPTIES the app's top-level AppleScript collections
10
+ * (oddities §25 / MODALX1), so `delete` reports `-1728 Can't get to do id` on
11
+ * a row the database holds open — issue #620's "ghost clone", which has no
12
+ * second cause;
13
+ * - a standing sheet GATES Things Cloud sync entirely (oddities §24), so writes
14
+ * land locally and silently never leave the Mac;
15
+ * - a Repeat dialog opened while Things is BACKGROUNDED becomes a detached
16
+ * window that nothing dismisses (oddities §26 / DRVLAT1 §5) — not its own
17
+ * Cancel, not Escape, not ⌘W, not a real HID click at its Cancel frame.
18
+ *
19
+ * The shipped driver never creates the third state (every dialog recipe
20
+ * activates Things first, which is exactly why that step is permanent), but a
21
+ * killed client, a harness tool-timeout or a person walking away mid-dialog can
22
+ * all leave the first two. {@link rescueStatus} reports them; the other two
23
+ * verbs act on them. This is the single home for both halves — the read-only
24
+ * census that was once the top-level `ui-state` command lives here now.
25
+ *
26
+ * THE THREE VERBS, separated by what they can cost you:
27
+ *
28
+ * - {@link rescueStatus} free, ungated, read-only — the census + the lock
29
+ * table + a "we must not touch that" verdict;
30
+ * - {@link rescueDismiss} gated two ways — presses ONE dialog's own Cancel;
31
+ * - {@link rescueRelaunch} gated two ways — ends the process and starts it
32
+ * again, the only cure measured for oddities §26.
33
+ *
34
+ * PERMISSIONS DOCTRINE. Nothing here raises a macOS consent dialog. The screen
35
+ * is read behind the prompt-free capability verdict, exactly as `doctor
36
+ * --ui-state` reads it, and a machine that has not granted the access is TOLD so
37
+ * rather than
38
+ * prompted. {@link rescueRelaunch} is the deliberate exception to needing that
39
+ * grant at all — see its own note.
40
+ */
41
+ import { createAuditWriter } from "./audit/log.js";
42
+ import { uiAllowed, uiCapability as uiCapabilityDefault } from "./capability.js";
43
+ import { loadConfig } from "./config.js";
44
+ import { PKG_VERSION } from "./contracts.js";
45
+ import { BASELINES } from "./db/baselines/index.js";
46
+ import { openConnection, ThingsDbOpenError } from "./db/connection.js";
47
+ import { compareToBaseline, observeSchema } from "./db/fingerprint.js";
48
+ import { locateThingsDb, ThingsDbNotFoundError } from "./db/locate.js";
49
+ import { auditDir, mutationLockPath } from "./paths.js";
50
+ import { formatHeldFor, readLockHolder } from "./write/lock.js";
51
+ import { censusUnverifiable, describeUiState, describeUnprovenProbes, SYNC_GATE_WARNING, THINGS_PROCESS, } from "./write/vectors/ui-state.js";
52
+ import { axCancelDialogScript, axCancelFrameScript, defaultUiRunner, parseFrameCenter, readLiveUiState, uiClickPointCommand, } from "./write/vectors/ui.js";
53
+ import { execFile } from "node:child_process";
54
+ // ---------------------------------------------------------------- timings
55
+ /**
56
+ * Budget for the AppleScript `quit` rung. A standing sheet swallows ⌘Q, and the
57
+ * scripting `quit` verb can itself block behind one — which is the whole reason
58
+ * this rung is bounded rather than awaited. Short on purpose: the rungs under it
59
+ * cost nothing and always work.
60
+ */
61
+ const QUIT_TIMEOUT_MS = 6_000;
62
+ /** How long each rung of the kill ladder waits for the process to actually go. */
63
+ const DEATH_WAIT_MS = 5_000;
64
+ /** How long to wait for a relaunched Things to be answering again. */
65
+ const RELAUNCH_WAIT_MS = 45_000;
66
+ /** Poll interval for every "has it happened yet" loop in this module. */
67
+ const POLL_MS = 250;
68
+ /** Per-hop budget for the two Cancel scripts (resolve + press). */
69
+ const CANCEL_TIMEOUT_MS = 10_000;
70
+ function run(cmd, args, timeoutMs) {
71
+ return new Promise((settle) => {
72
+ execFile(cmd, args, { timeout: timeoutMs }, (err, stdout) => {
73
+ settle({ ok: err === null, out: String(stdout ?? "").trim() });
74
+ });
75
+ });
76
+ }
77
+ /** `pgrep -x Things3` — the process table, asked by name, costing no grant. */
78
+ async function livePids() {
79
+ const res = await run("/usr/bin/pgrep", ["-x", THINGS_PROCESS], 5_000);
80
+ if (!res.ok)
81
+ return []; // pgrep exits 1 when nothing matches
82
+ return res.out
83
+ .split(/\s+/)
84
+ .map((s) => Number(s))
85
+ .filter((n) => Number.isInteger(n) && n > 0);
86
+ }
87
+ function realSchemaStatus() {
88
+ let path;
89
+ try {
90
+ path = locateThingsDb().path;
91
+ }
92
+ catch (err) {
93
+ return {
94
+ ok: false,
95
+ detail: err instanceof ThingsDbNotFoundError
96
+ ? "the Things database could not be found"
97
+ : "the Things database could not be located",
98
+ };
99
+ }
100
+ let conn;
101
+ try {
102
+ conn = openConnection(path);
103
+ }
104
+ catch (err) {
105
+ return {
106
+ ok: false,
107
+ detail: err instanceof ThingsDbOpenError
108
+ ? "the Things database is there but would not open yet"
109
+ : "the Things database would not open",
110
+ };
111
+ }
112
+ try {
113
+ const status = compareToBaseline(observeSchema(conn.db), BASELINES);
114
+ return status.kind === "ok"
115
+ ? { ok: true, detail: "the database opened and reads as the shape this version expects" }
116
+ : {
117
+ ok: false,
118
+ detail: "the database opened but no longer matches the shape this version expects",
119
+ };
120
+ }
121
+ finally {
122
+ conn.close();
123
+ }
124
+ }
125
+ function resolve(deps) {
126
+ const env = deps.env ?? process.env;
127
+ return {
128
+ uiCapability: deps.uiCapability ?? (() => uiCapabilityDefault()),
129
+ run: deps.run ?? defaultUiRunner,
130
+ env,
131
+ now: deps.now ?? Date.now,
132
+ pidAlive: deps.pidAlive ??
133
+ ((pid) => {
134
+ try {
135
+ process.kill(pid, 0);
136
+ return true;
137
+ }
138
+ catch {
139
+ return false;
140
+ }
141
+ }),
142
+ thingsPids: deps.thingsPids ?? livePids,
143
+ signal: deps.signal ??
144
+ ((pid, sig) => {
145
+ try {
146
+ process.kill(pid, sig);
147
+ }
148
+ catch {
149
+ // already gone — that is the outcome we wanted
150
+ }
151
+ }),
152
+ launch: deps.launch ??
153
+ (async () => {
154
+ const res = await run("/usr/bin/open", ["-g", "-a", THINGS_PROCESS], 15_000);
155
+ return {
156
+ ok: res.ok,
157
+ detail: res.ok ? "Things was started in the background" : "Things would not start",
158
+ };
159
+ }),
160
+ schemaStatus: deps.schemaStatus ?? (() => realSchemaStatus()),
161
+ sleep: deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))),
162
+ audit: deps.audit ??
163
+ (() => {
164
+ const config = loadConfig(env);
165
+ return config.auditEnabled
166
+ ? createAuditWriter({ dir: auditDir(env), secrets: [], enabled: true })
167
+ : null;
168
+ })(),
169
+ };
170
+ }
171
+ /**
172
+ * Read everything a stuck machine can be asked about, without touching any of
173
+ * it. Never throws and never refuses: the cases this exists for are exactly the
174
+ * ones where something is already wrong, so every failure is a REPORTED state.
175
+ */
176
+ export async function rescueStatus(deps = {}) {
177
+ const d = resolve(deps);
178
+ const path = mutationLockPath(d.env);
179
+ const holder = readLockHolder(path, { pidAlive: d.pidAlive, now: d.now });
180
+ const lock = {
181
+ path,
182
+ held: holder.holder !== null,
183
+ pid: holder.holder?.pid ?? null,
184
+ since: holder.holder?.ts ?? null,
185
+ heldFor: holder.heldForMs === null ? null : formatHeldFor(holder.heldForMs),
186
+ alive: holder.alive,
187
+ suspect: holder.suspect,
188
+ detail: holder.holder === null
189
+ ? "no change is holding the lock"
190
+ : holder.alive
191
+ ? `pid ${holder.holder.pid} has held it${holder.heldForMs === null ? "" : ` for ${formatHeldFor(holder.heldForMs)}`}${holder.suspect
192
+ ? " — far longer than any change takes; that process may be hung, and killing it releases the lock"
193
+ : ""}`
194
+ : `pid ${holder.holder.pid} holds it but is no longer running — the next change takes it`,
195
+ };
196
+ const capability = d.uiCapability();
197
+ if (!uiAllowed(capability)) {
198
+ return {
199
+ screenReadable: false,
200
+ detail: `the Things window cannot be read on this machine — ${capability.detail}`,
201
+ state: null,
202
+ lock,
203
+ foreignModal: null,
204
+ warnings: [],
205
+ remediation: capability.remediation,
206
+ };
207
+ }
208
+ const state = await readLiveUiState(d.run);
209
+ if (state === null) {
210
+ return {
211
+ screenReadable: true,
212
+ detail: "the window and focus state could not be read — Things may have stopped answering, or a " +
213
+ "system dialog is covering the screen",
214
+ state: null,
215
+ lock,
216
+ foreignModal: null,
217
+ warnings: [],
218
+ remediation: [
219
+ "look at the screen: a macOS privacy or consent dialog is not exposed to any app and has " +
220
+ "to be answered by hand",
221
+ "if Things itself has stopped answering, `things rescue relaunch` ends it and starts it again",
222
+ ],
223
+ };
224
+ }
225
+ const foreignModal = state.inspectable
226
+ ? null
227
+ : {
228
+ owner: state.frontmostApp,
229
+ detail: "a system dialog owns the screen — macOS does not expose it to other apps, so nothing " +
230
+ `here can identify or close it${state.frontmostApp === null ? "" : ` (frontmost application: ${state.frontmostApp})`}`,
231
+ };
232
+ return {
233
+ screenReadable: true,
234
+ detail: describeUiState(state),
235
+ state,
236
+ lock,
237
+ foreignModal,
238
+ warnings: state.sheetOpen ? [SYNC_GATE_WARNING] : [],
239
+ remediation: statusAdvice(state, foreignModal, lock),
240
+ };
241
+ }
242
+ /** What the operator should do, given everything the census and the lock proved. */
243
+ function statusAdvice(state, foreignModal, lock) {
244
+ const next = [];
245
+ if (foreignModal !== null) {
246
+ next.push("answer that dialog at the screen — no rescue command will press a button on a dialog it " +
247
+ "cannot identify, and this one belongs to macOS rather than to Things");
248
+ }
249
+ else if (censusUnverifiable(state)) {
250
+ next.push(`part of the screen read did not answer (${describeUnprovenProbes(state)}) — run this ` +
251
+ "again, and if it keeps happening `things rescue relaunch` ends Things and starts it again");
252
+ }
253
+ else if (state.sheetOpen) {
254
+ if (state.sheetKind === "other") {
255
+ next.push("the open dialog is not one this command recognizes, so it will not press its buttons — " +
256
+ "close it at the screen, or run `things rescue relaunch` to end Things and start it again");
257
+ }
258
+ else {
259
+ next.push("`things rescue dismiss` closes the dialog in front by pressing its own Cancel button" +
260
+ (state.sheetDepth > 1
261
+ ? `; ${state.sheetDepth} are stacked, and each one needs its own invocation`
262
+ : ""));
263
+ }
264
+ if (state.sheetForm === "detached") {
265
+ next.push("this dialog is the detached kind, which has been measured to ignore every way of closing " +
266
+ "it — expect `things rescue dismiss` to report that it is still there, and use " +
267
+ "`things rescue relaunch`");
268
+ }
269
+ }
270
+ if (lock.suspect) {
271
+ next.push(`the change lock has been held by pid ${lock.pid} for ${lock.heldFor ?? "a long time"}; if ` +
272
+ `that process is hung, \`kill ${lock.pid}\` releases it`);
273
+ }
274
+ return next;
275
+ }
276
+ /**
277
+ * Close the dialog IN FRONT by pressing its own Cancel button — FGRD2's proven
278
+ * Cancel, generalized off the drive's abort path and onto a dialog no drive
279
+ * opened.
280
+ *
281
+ * EXACTLY ONE LIFO LEVEL PER INVOCATION. Sheets nest as `AXSheet` children of
282
+ * the sheet below and dismiss strictly last-in-first-out (MODALX1 §6). A loop
283
+ * that cleared a whole stack in one command would be a loop pressing an unknown
284
+ * number of buttons on an unknown number of dialogs; instead this closes the top
285
+ * one, re-reads, and reports how many remain so the caller decides.
286
+ *
287
+ * PROVEN, never assumed. The press reporting success is NOT closure: the
288
+ * detached editor of oddities §26 accepts an AXPress on its Cancel and stays
289
+ * exactly where it is. Closure is decided by a fresh census showing the stack one
290
+ * level shorter, and an unconfirmable press is reported as unconfirmed.
291
+ *
292
+ * WHAT IT CAN COST YOU: the dialog's own pending edits, and nothing else. The
293
+ * database is untouched, and a URL command parked behind a consent sheet is
294
+ * discarded with it (URLEN1).
295
+ */
296
+ export async function rescueDismiss(ack = {}, deps = {}) {
297
+ const d = resolve(deps);
298
+ const startedAt = new Date(d.now());
299
+ const refuse = (detail, remediation, before = null) => {
300
+ const result = {
301
+ outcome: "refused",
302
+ how: null,
303
+ before,
304
+ after: null,
305
+ levelsRemaining: before?.sheetDepth ?? null,
306
+ detail,
307
+ remediation,
308
+ notes: [],
309
+ warnings: before?.sheetOpen === true ? [SYNC_GATE_WARNING] : [],
310
+ };
311
+ return result;
312
+ };
313
+ if (ack.dangerouslyDismissDialog !== true) {
314
+ return refuse("this closes a dialog that is open in Things, discarding whatever was typed into it", [
315
+ "pass dangerouslyDismissDialog (--dangerously-dismiss-dialog) to proceed",
316
+ "run `things rescue status` first to see what is open",
317
+ ]);
318
+ }
319
+ const capability = d.uiCapability();
320
+ if (!uiAllowed(capability)) {
321
+ return refuse(`the Things window cannot be reached on this machine — ${capability.detail}`, capability.remediation);
322
+ }
323
+ const before = await readLiveUiState(d.run);
324
+ if (before === null || censusUnverifiable(before)) {
325
+ return refuse(before === null
326
+ ? "the window state could not be read, so there is no way to know which dialog a Cancel " +
327
+ "would land on — nothing was pressed"
328
+ : `the window state could not be established (${describeUnprovenProbes(before)}), so ` +
329
+ "there is no way to know which dialog a Cancel would land on — nothing was pressed", [
330
+ "run `things rescue status` and try again once the screen reads cleanly",
331
+ "if it will not, `things rescue relaunch` ends Things and starts it again",
332
+ ], before);
333
+ }
334
+ if (!before.inspectable) {
335
+ return refuse("a system dialog owns the screen — macOS does not expose it to other apps, so nothing here " +
336
+ `can identify it, and nothing here will press its buttons${before.frontmostApp === null ? "" : ` (frontmost application: ${before.frontmostApp})`}`, ["answer that dialog at the screen; it belongs to macOS, not to Things"], before);
337
+ }
338
+ if (!before.sheetOpen) {
339
+ return {
340
+ outcome: "no-dialog",
341
+ how: null,
342
+ before,
343
+ after: before,
344
+ levelsRemaining: 0,
345
+ detail: "no dialog is open in Things — nothing was stranded and nothing was pressed",
346
+ remediation: [],
347
+ notes: [],
348
+ warnings: [],
349
+ };
350
+ }
351
+ if (before.sheetKind === "other") {
352
+ return refuse("the dialog in front is not one this command recognizes, and it will not press buttons on a " +
353
+ "dialog it cannot identify", [
354
+ "close it at the screen if you can see it",
355
+ "otherwise `things rescue relaunch` ends Things and starts it again",
356
+ ], before);
357
+ }
358
+ // --- press its own Cancel, then PROVE it.
359
+ const notes = [];
360
+ const pressed = await d.run({
361
+ primitive: "resolve",
362
+ label: "press the open dialog's Cancel button",
363
+ script: axCancelDialogScript(),
364
+ }, CANCEL_TIMEOUT_MS);
365
+ const verdict = pressed.stdout.trim();
366
+ if (pressed.ok && verdict === "NO-CANCEL") {
367
+ return refuse("the dialog in front has no Cancel button, so there is nothing here to press", ["`things rescue relaunch` ends Things and starts it again"], before);
368
+ }
369
+ if (pressed.ok && verdict === "NO-DIALOG") {
370
+ const after = await readLiveUiState(d.run);
371
+ return {
372
+ outcome: "no-dialog",
373
+ how: null,
374
+ before,
375
+ after,
376
+ levelsRemaining: after?.sheetDepth ?? null,
377
+ detail: "the dialog closed on its own between reading the screen and pressing Cancel",
378
+ remediation: [],
379
+ notes: [],
380
+ warnings: after?.sheetOpen === true ? [SYNC_GATE_WARNING] : [],
381
+ };
382
+ }
383
+ let after = await readLiveUiState(d.run);
384
+ let how = pressed.ok && verdict === "OK" ? "cancel-button" : null;
385
+ // The synthesized-click fallback, on the same button, resolved by address.
386
+ if (!closedOneLevel(before, after)) {
387
+ notes.push("its Cancel button did not close it, so the button was clicked at its own position");
388
+ const frame = await d.run({ primitive: "resolve", label: "find the Cancel button", script: axCancelFrameScript() }, CANCEL_TIMEOUT_MS);
389
+ const center = frame.ok ? parseFrameCenter(frame.stdout) : null;
390
+ if (center !== null) {
391
+ await d.run(uiClickPointCommand(center.x, center.y, "click the open dialog's Cancel button"), CANCEL_TIMEOUT_MS);
392
+ after = await readLiveUiState(d.run);
393
+ if (closedOneLevel(before, after))
394
+ how = "cancel-click";
395
+ }
396
+ }
397
+ const result = judgeDismissal(before, after, how, notes);
398
+ recordRescue(d, {
399
+ op: "rescue.dismiss",
400
+ startedAt,
401
+ result: result.outcome === "dismissed" ? "ok" : `blocked:${result.outcome}`,
402
+ requested: { dangerouslyDismissDialog: true },
403
+ pre: censusRecord(before),
404
+ observed: after === null ? null : censusRecord(after),
405
+ });
406
+ return result;
407
+ }
408
+ /**
409
+ * Did the stack get exactly one level shorter? This — not "the press returned
410
+ * OK", and not "no sheet is open" — is what closure means here: dismissing the
411
+ * top of a stack of two correctly leaves one standing, and a press that reported
412
+ * success against the §26 detached editor changes nothing at all.
413
+ */
414
+ function closedOneLevel(before, after) {
415
+ if (after === null || censusUnverifiable(after) || !after.inspectable)
416
+ return false;
417
+ return after.sheetDepth < before.sheetDepth;
418
+ }
419
+ function judgeDismissal(before, after, how, notes) {
420
+ const levelsRemaining = after === null ? null : after.sheetDepth;
421
+ if (closedOneLevel(before, after) && after !== null) {
422
+ const remaining = after.sheetDepth;
423
+ return {
424
+ outcome: "dismissed",
425
+ how,
426
+ before,
427
+ after,
428
+ levelsRemaining: remaining,
429
+ detail: remaining === 0
430
+ ? "the dialog was closed and no dialog is open in Things"
431
+ : `the dialog in front was closed; ${remaining} still open underneath it`,
432
+ remediation: remaining === 0
433
+ ? []
434
+ : ["run `things rescue dismiss` again to close the next one — they close one at a time"],
435
+ notes,
436
+ warnings: remaining === 0 ? [] : [SYNC_GATE_WARNING],
437
+ };
438
+ }
439
+ if (after === null || censusUnverifiable(after) || !after.inspectable) {
440
+ return {
441
+ outcome: "unverified",
442
+ how: null,
443
+ before,
444
+ after,
445
+ levelsRemaining,
446
+ detail: "Cancel was pressed, and the screen could not be read afterwards — so whether the dialog " +
447
+ "closed is unknown, and this reports that rather than assuming it",
448
+ remediation: [
449
+ "run `things rescue status` to see the current state",
450
+ "if the dialog is still there, `things rescue relaunch` ends Things and starts it again",
451
+ ],
452
+ notes,
453
+ warnings: [SYNC_GATE_WARNING],
454
+ };
455
+ }
456
+ return {
457
+ outcome: "still-open",
458
+ how: null,
459
+ before,
460
+ after,
461
+ levelsRemaining,
462
+ detail: "Cancel was pressed, both by the button and at its position on screen, and the dialog is " +
463
+ "still open" +
464
+ (before.sheetForm === "detached"
465
+ ? " — this is the detached kind of dialog, which has been measured to ignore every way of " +
466
+ "closing it"
467
+ : ""),
468
+ remediation: [
469
+ "`things rescue relaunch` ends Things and starts it again — it is the only thing measured to " +
470
+ "clear this",
471
+ ],
472
+ notes,
473
+ warnings: [SYNC_GATE_WARNING],
474
+ };
475
+ }
476
+ /**
477
+ * End Things and start it again — the nuclear rung, and the ONLY cure this
478
+ * project has measured for the detached editor of oddities §26. DRVLAT1 §5 tried
479
+ * every other route against the same live dialog (its own Cancel backgrounded,
480
+ * foregrounded and after `AXRaise`; Escape; ⌘W then re-activate; a real HID click
481
+ * at the AX-resolved Cancel frame) and every one was inert.
482
+ *
483
+ * THE LADDER, each rung bounded:
484
+ *
485
+ * 1. ask Things to quit through its own scripting interface. Bounded, because
486
+ * a standing sheet swallows ⌘Q and the `quit` verb can block behind one.
487
+ * 2. SIGTERM, and wait for the process to go.
488
+ * 3. SIGKILL — the lab's canonical reset for a modal that will not clear.
489
+ * 4. start it again in the BACKGROUND: we do not steal the screen to fix the
490
+ * screen.
491
+ * 5. prove it: the process is answering, the database still reads as the shape
492
+ * this version expects, and no dialog is open.
493
+ *
494
+ * WHAT IS LOST: the Things database is write-ahead-logged, so everything already
495
+ * committed survives a kill unconditionally. The ceiling is what was never
496
+ * committed — the edits sitting in the dialog being destroyed.
497
+ *
498
+ * WHAT COMES BACK: sync. While a dialog stands, Things stops sending changes to
499
+ * Things Cloud (oddities §24); killing the dialog with the process releases that,
500
+ * and everything written on this Mac in the meantime goes out.
501
+ *
502
+ * DELIBERATELY NOT GATED ON THE GUI CAPABILITY. Every other verb that touches the
503
+ * app needs the GUI grant, because it synthesizes input into someone's window.
504
+ * This one signals a process the user already owns, which needs no grant at all —
505
+ * and the machines that need it most are exactly the ones where the Accessibility
506
+ * path is what broke. The quit rung and the census still run through the granted
507
+ * seam when it is available, and are skipped without complaint when it is not.
508
+ */
509
+ export async function rescueRelaunch(ack = {}, deps = {}) {
510
+ const d = resolve(deps);
511
+ const startedAt = new Date(d.now());
512
+ const profile = loadConfig(d.env).profile;
513
+ const ladder = [];
514
+ const refuse = (detail, remediation) => ({
515
+ outcome: "refused",
516
+ endedBy: null,
517
+ before: null,
518
+ after: null,
519
+ ladder,
520
+ detail,
521
+ remediation,
522
+ notes: [],
523
+ warnings: [],
524
+ });
525
+ if (ack.yes !== true) {
526
+ return refuse("this ends the Things application and starts it again; anything typed into an open dialog " +
527
+ "and not yet saved is discarded", [
528
+ "pass --yes to proceed",
529
+ "run `things rescue status` first, and `things rescue dismiss` if the dialog will close on its own",
530
+ ]);
531
+ }
532
+ if (profile === "workstation" && ack.dangerouslyForceQuit !== true) {
533
+ return refuse("this machine is configured as a workstation, where someone may be sitting in front of the " +
534
+ "dialog this would destroy", [
535
+ "pass --dangerously-force-quit as well to proceed",
536
+ "or set `things config set profile dedicated-server` on a machine nobody is sitting at",
537
+ ]);
538
+ }
539
+ const capable = uiAllowed(d.uiCapability());
540
+ const before = capable ? await readLiveUiState(d.run) : null;
541
+ ladder.push(before === null
542
+ ? capable
543
+ ? "the screen could not be read before starting"
544
+ : "the screen was not read (this machine has not granted the access; it is not needed for this)"
545
+ : `before: ${describeUiState(before)}`);
546
+ // Rung 1 — ask nicely, on a short leash.
547
+ let pids = await d.thingsPids();
548
+ let endedBy = null;
549
+ if (pids.length === 0) {
550
+ endedBy = "not-running";
551
+ ladder.push("Things was not running");
552
+ }
553
+ else {
554
+ if (capable) {
555
+ const quit = await d.run({
556
+ primitive: "resolve",
557
+ label: "ask Things to quit",
558
+ script: `tell application "${THINGS_PROCESS}" to quit`,
559
+ }, QUIT_TIMEOUT_MS);
560
+ ladder.push(quit.ok ? "asked Things to quit" : "asked Things to quit — it did not answer");
561
+ }
562
+ else {
563
+ ladder.push("skipped asking Things to quit (that needs an access this machine has not granted)");
564
+ }
565
+ if (await waitForDeath(d, DEATH_WAIT_MS)) {
566
+ endedBy = "quit";
567
+ ladder.push("it quit on its own");
568
+ }
569
+ }
570
+ // Rungs 2 and 3 — SIGTERM, then SIGKILL.
571
+ for (const [sig, label] of [
572
+ ["SIGTERM", "sigterm"],
573
+ ["SIGKILL", "sigkill"],
574
+ ]) {
575
+ if (endedBy !== null)
576
+ break;
577
+ pids = await d.thingsPids();
578
+ if (pids.length === 0)
579
+ break;
580
+ for (const pid of pids)
581
+ d.signal(pid, sig);
582
+ ladder.push(`sent ${sig} to ${pids.map((p) => `pid ${p}`).join(", ")}`);
583
+ if (await waitForDeath(d, DEATH_WAIT_MS)) {
584
+ endedBy = label;
585
+ ladder.push("the process ended");
586
+ }
587
+ }
588
+ if (endedBy === null && (await d.thingsPids()).length > 0) {
589
+ return {
590
+ outcome: "failed",
591
+ endedBy: null,
592
+ before,
593
+ after: null,
594
+ ladder,
595
+ detail: "Things would not end, even when killed — nothing was restarted",
596
+ remediation: [
597
+ "quit it from the Force Quit window (⌥⌘⎋) or restart the Mac",
598
+ "this should not happen; it is worth reporting",
599
+ ],
600
+ notes: [],
601
+ warnings: [],
602
+ };
603
+ }
604
+ // Rung 4 — start it again, in the background.
605
+ const launched = await d.launch();
606
+ ladder.push(launched.detail);
607
+ if (!launched.ok) {
608
+ return {
609
+ outcome: "failed",
610
+ endedBy,
611
+ before,
612
+ after: null,
613
+ ladder,
614
+ detail: "Things was ended but would not start again",
615
+ remediation: ["start Things by hand, then run `things rescue status` to confirm it is clear"],
616
+ notes: [],
617
+ warnings: [],
618
+ };
619
+ }
620
+ // Rung 5 — prove it came back clean.
621
+ const after = capable ? await waitForHealthy(d) : null;
622
+ const schema = d.schemaStatus();
623
+ ladder.push(schema.detail);
624
+ const notes = [
625
+ "everything already saved survived: only what was typed into the open dialog and not saved is gone",
626
+ "Things sends changes to Things Cloud again now that no dialog is standing",
627
+ ];
628
+ const back = after !== null && after.thingsRunning && !after.sheetOpen;
629
+ ladder.push(after === null
630
+ ? capable
631
+ ? "the screen could not be read after starting"
632
+ : "the screen was not read after starting"
633
+ : `after: ${describeUiState(after)}`);
634
+ const result = {
635
+ outcome: "relaunched",
636
+ endedBy,
637
+ before,
638
+ after,
639
+ ladder,
640
+ detail: back
641
+ ? "Things was ended and started again, and no dialog is open"
642
+ : after !== null && after.sheetOpen
643
+ ? "Things was ended and started again, and a dialog is open again"
644
+ : "Things was ended and started again; whether a dialog is open could not be confirmed from here",
645
+ remediation: back && schema.ok ? [] : ["run `things rescue status` to see the current state"],
646
+ notes,
647
+ warnings: schema.ok
648
+ ? []
649
+ : [
650
+ "the Things database no longer reads as the shape this version expects — run `things doctor`",
651
+ ],
652
+ };
653
+ recordRescue(d, {
654
+ op: "rescue.relaunch",
655
+ startedAt,
656
+ result: "ok",
657
+ requested: {
658
+ yes: true,
659
+ ...(ack.dangerouslyForceQuit === true && { dangerouslyForceQuit: true }),
660
+ },
661
+ pre: before === null ? null : censusRecord(before),
662
+ observed: {
663
+ ...(after === null ? {} : censusRecord(after)),
664
+ endedBy: endedBy ?? "unknown",
665
+ schemaOk: schema.ok,
666
+ },
667
+ });
668
+ return result;
669
+ }
670
+ /** Poll until no Things process is left, or the budget runs out. */
671
+ async function waitForDeath(d, budgetMs) {
672
+ const deadline = d.now() + budgetMs;
673
+ for (;;) {
674
+ if ((await d.thingsPids()).length === 0)
675
+ return true;
676
+ if (d.now() >= deadline)
677
+ return false;
678
+ await d.sleep(POLL_MS);
679
+ }
680
+ }
681
+ /** Poll until the relaunched app is answering the census, or the budget runs out. */
682
+ async function waitForHealthy(d) {
683
+ const deadline = d.now() + RELAUNCH_WAIT_MS;
684
+ let last = null;
685
+ for (;;) {
686
+ last = await readLiveUiState(d.run);
687
+ if (last !== null && last.thingsRunning && !censusUnverifiable(last))
688
+ return last;
689
+ if (d.now() >= deadline)
690
+ return last;
691
+ await d.sleep(POLL_MS);
692
+ }
693
+ }
694
+ // ------------------------------------------------------------ the record
695
+ /**
696
+ * The census as a change-history payload. ROLE COUNTS and kinds only — never a
697
+ * control's value and never a window title, exactly as the census itself
698
+ * promises (a stranded Repeat sheet is showing the user's own to-do text).
699
+ */
700
+ function censusRecord(state) {
701
+ return {
702
+ sheetOpen: state.sheetOpen,
703
+ sheetKind: state.sheetKind,
704
+ sheetForm: state.sheetForm,
705
+ sheetDepth: state.sheetDepth,
706
+ sheetControls: state.sheetControls,
707
+ thingsRunning: state.thingsRunning,
708
+ thingsFrontmost: state.thingsFrontmost,
709
+ frontmostApp: state.frontmostApp,
710
+ inspectable: state.inspectable,
711
+ };
712
+ }
713
+ /**
714
+ * Record what a rescue ACTION did. `status` records nothing — it changes
715
+ * nothing. Best-effort by construction: the writer never throws, and a machine
716
+ * that cannot write its history is still a machine that needed rescuing.
717
+ */
718
+ function recordRescue(d, fields) {
719
+ if (d.audit === null)
720
+ return;
721
+ const config = loadConfig(d.env);
722
+ d.audit.append({
723
+ v: 1,
724
+ ts: fields.startedAt.toISOString(),
725
+ actor: config.actor,
726
+ host: config.host,
727
+ op: fields.op,
728
+ uuid: null,
729
+ vector: "ui",
730
+ disruption: 3,
731
+ invocation: null,
732
+ requested: fields.requested,
733
+ pre: fields.pre,
734
+ observed: fields.observed,
735
+ result: fields.result,
736
+ verify: null,
737
+ durationMs: d.now() - fields.startedAt.getTime(),
738
+ env: { pkg: PKG_VERSION, dbVersion: null, fingerprint: "unknown" },
739
+ });
740
+ }
741
+ // ---------------------------------------------------------------- rendering
742
+ /**
743
+ * The human render of `things rescue status`.
744
+ *
745
+ * A ROW WHOSE PROBE DID NOT ANSWER SAYS SO (issue #629). Printing a field's
746
+ * unset default ("none", "unknown") beside rows that WERE measured is exactly
747
+ * what made a stalled inspection read as a clean screen in the field, so an
748
+ * unproven probe renders as "not established" rather than as its default, and
749
+ * the probes are named again in full on the `unproven:` row.
750
+ */
751
+ export function rescueStatusLines(report) {
752
+ const lines = ["── Things ──", `screen: ${report.detail}`];
753
+ const state = report.state;
754
+ if (state !== null) {
755
+ const unproven = (p) => state.stalledProbes.includes(p) || state.failedProbes.includes(p);
756
+ lines.push(`dialog: ${unproven("dialog")
757
+ ? "not established"
758
+ : state.sheetKind === "none"
759
+ ? "none"
760
+ : `${state.sheetKind} (${state.sheetForm}; ${state.sheetControls ?? "no census"})`}`, `stacked: ${unproven("dialog") ? "not established" : state.sheetDepth}`, `frontmost: ${unproven("frontmost")
761
+ ? "not established"
762
+ : `${state.frontmostApp ?? (unproven("frontapp") ? "not established" : "unknown")}${state.thingsFrontmost ? " (Things)" : ""}`}`,
763
+ // Which application owns the KEYBOARD, and what has focus inside it —
764
+ // not the same question as which application owns the screen, and the
765
+ // one that explains a keystroke landing in the void.
766
+ `focus: ${unproven("focus")
767
+ ? "not established"
768
+ : state.focusOwner === null
769
+ ? "unknown"
770
+ : `${state.focusOwner.app} · ${state.focusOwner.role || "no focused element"}${state.focusOwner.subrole === null ? "" : ` / ${state.focusOwner.subrole}`}`}`, `inspectable: ${state.inspectable ? "yes" : "no — a system dialog macOS does not expose"}`);
771
+ const unprovenText = describeUnprovenProbes(state);
772
+ if (unprovenText !== "")
773
+ lines.push(`unproven: ${unprovenText}`);
774
+ }
775
+ lines.push("── Change lock ──", `lock: ${report.lock.detail}`);
776
+ if (report.foreignModal !== null) {
777
+ lines.push("── Not ours ──", `dialog: ${report.foreignModal.detail}`);
778
+ }
779
+ for (const warning of report.warnings)
780
+ lines.push(` warning: ${warning}`);
781
+ for (const step of report.remediation)
782
+ lines.push(` next: ${step}`);
783
+ return lines;
784
+ }
785
+ /** The human render of `things rescue dismiss`. */
786
+ export function rescueDismissLines(result) {
787
+ const lines = [`${result.outcome}: ${result.detail}`];
788
+ if (result.levelsRemaining !== null)
789
+ lines.push(` dialogs still open: ${result.levelsRemaining}`);
790
+ for (const note of result.notes)
791
+ lines.push(` note: ${note}`);
792
+ for (const warning of result.warnings)
793
+ lines.push(` warning: ${warning}`);
794
+ for (const step of result.remediation)
795
+ lines.push(` next: ${step}`);
796
+ return lines;
797
+ }
798
+ /** The human render of `things rescue relaunch`. */
799
+ export function rescueRelaunchLines(result) {
800
+ const lines = [`${result.outcome}: ${result.detail}`];
801
+ for (const step of result.ladder)
802
+ lines.push(` · ${step}`);
803
+ for (const note of result.notes)
804
+ lines.push(` note: ${note}`);
805
+ for (const warning of result.warnings)
806
+ lines.push(` warning: ${warning}`);
807
+ for (const step of result.remediation)
808
+ lines.push(` next: ${step}`);
809
+ return lines;
810
+ }
811
+ //# sourceMappingURL=rescue.js.map