conductor-remote 1.34.2 → 1.34.4

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.
@@ -0,0 +1,1095 @@
1
+ (*
2
+ Everything the relay drives Conductor's UI with, in one place.
3
+
4
+ Loaded verbatim by src/writes.ts, which appends a few lines naming the
5
+ handlers to run and hands the result to `osascript`. It lives in its own file
6
+ rather than a TypeScript template literal for three reasons: an editor can
7
+ highlight it, `yarn verify` can compile-check it (scripts/check-applescript.ts,
8
+ which is the only automated test this code has), and a backtick in a comment
9
+ no longer terminates the string it used to sit inside — so this sentence,
10
+ which contains `one`, is now harmless.
11
+
12
+ The target rides in on the environment (RELAY_WS_*, RELAY_TAB_*, RELAY_SET_*,
13
+ RELAY_PROMPT_FILE) rather than being interpolated, which keeps AppleScript
14
+ string escaping out of the picture entirely.
15
+
16
+ Two traps to respect when editing:
17
+
18
+ - Inside a `tell application "System Events"` block, ordinary-looking variable
19
+ names resolve as System Events terms. `tabs` and `groups` become "every
20
+ tab/group of the process", so a loop over your own list quietly iterates the
21
+ wrong thing and the handler returns nothing. Keep logic outside the tell and
22
+ reach in through one-line helpers, or name the variables `strip` and `pane`.
23
+ - Every read is rooted at `window 1`, and Conductor keeps running with its
24
+ windows closed. Go through `requireWindow` / `webArea` so a window that
25
+ closed mid-run says so in words.
26
+
27
+ How the chat targeting works, and why each step is here:
28
+
29
+ `openViaDeepLink` normally selects the chat already, by id. The handlers below
30
+ are what confirm that, and what still does the work for the fallback focus
31
+ paths, which only reach the right workspace. A workspace holds several chats
32
+ and Conductor keeps typing in whichever tab is already active, so a send
33
+ addressed to a non-active chat would land in the wrong agent.
34
+
35
+ Conductor's webview exposes its whole tree through macOS Accessibility. The
36
+ chat strip is an AXTabGroup whose AXRadioButtons are the tabs (AXValue marks
37
+ the selected one, AXPress switches to it and does *not* close the chat). The
38
+ strip's order matches reads.listSessions (created_at ASC), so the caller
39
+ addresses a tab by 1-based index and we cross-check the label.
40
+
41
+ Three traps that targeting has to survive:
42
+
43
+ - The tabs are not the strip's direct children. Each sits one AXGroup deep and
44
+ is named for its own close action ("Close chat <title>"), while a "Close file
45
+ view" radio button, which is not a chat, *is* a direct child. Reading the
46
+ direct children and stopping there made every chat strip look like a one-tab
47
+ strip called "Close file view", which then lost the scoring below to the
48
+ terminal panel and failed every send with "chat tab 1 not found". So
49
+ `stripRadios` reads both levels and `chatTabs` keeps the "Close chat" ones.
50
+ - The terminal panel is an AXTabGroup too (radio buttons named Setup / Run /
51
+ Terminal 1), a sibling of the chat strip in the same pane. Picking "the first
52
+ tab group" would sometimes press a terminal tab, so candidates are *scored*
53
+ on tab count plus label and a tie refuses to act.
54
+ - The palette can land on the wrong workspace (a loose query matches a command;
55
+ a deleted branch opens a modal). The pane header carries the branch and repo,
56
+ so we read them back and bail if they disagree.
57
+
58
+ Target is read from RELAY_TAB_{INDEX,COUNT,TITLE} and RELAY_WS_{BRANCH,REPO};
59
+ index 0 disables the tab step. Every failure path errors out so the caller
60
+ aborts *before* typing — landing in the wrong chat is worse than not sending.
61
+ *)
62
+
63
+ on splitLines(s)
64
+ set saved to AppleScript's text item delimiters
65
+ set AppleScript's text item delimiters to linefeed
66
+ set parts to text items of s
67
+ set AppleScript's text item delimiters to saved
68
+ return parts
69
+ end splitLines
70
+
71
+ on windowProbe()
72
+ -- One read, three outcomes, none of them guessed: {count, errNum, errText}.
73
+ -- count is -1 when macOS refused the read at all. Everything that wants to
74
+ -- know about windows goes through here, because "returned 0" and "refused to
75
+ -- answer" are different facts and only this handler still has both.
76
+ -- The timeout is load-bearing: a wedged Conductor (seen live after a restart
77
+ -- collided with a relaunch) answers deep links but hangs every AX read, and
78
+ -- without a cap this call blocks until the node-side ceiling kills the whole
79
+ -- run as "took too long" — 28s of silence instead of a named cause. 6s turns
80
+ -- that into error -1712, which refusalReason maps to words.
81
+ try
82
+ with timeout of 6 seconds
83
+ tell application "System Events" to tell process "Conductor"
84
+ set winCount to (count of windows)
85
+ end tell
86
+ end timeout
87
+ return {winCount, 0, ""}
88
+ on error errText number errNum
89
+ return {-1, errNum, errText}
90
+ end try
91
+ end windowProbe
92
+
93
+ on hasWindow()
94
+ -- Also false when the process is gone or macOS refused: callers that only
95
+ -- need a boolean keep getting one. windowFailure() is what tells them why.
96
+ return (item 1 of my windowProbe()) > 0
97
+ end hasWindow
98
+
99
+ on refusalReason(probe)
100
+ -- macOS's own words, matched on the error it actually returned rather than on
101
+ -- a capability flag we hope is accurate. "UI elements enabled" was the earlier
102
+ -- attempt at this and is not trustworthy — it can report true for a process
103
+ -- that is not itself trusted, and then the refusal reappears downstream as
104
+ -- "no open window". These strings are the two grants the write path needs.
105
+ set errNum to item 2 of probe
106
+ set errText to item 3 of probe
107
+ if errText contains "assistive access" then return "the relay is not trusted for Accessibility - grant it to the node binary running the relay in System Settings > Privacy & Security > Accessibility, then restart the relay (upgrading node silently revokes it). macOS said: " & errText
108
+ if errNum is -1743 or errText contains "Not authorized" then return "macOS blocked the relay from controlling the UI - grant Automation permission to the node binary running the relay in System Settings > Privacy & Security > Automation. macOS said: " & errText
109
+ if errNum is -1712 then return "Conductor stopped answering UI reads (the Accessibility query timed out) - the app looks wedged even though it may still process deep links. Force-quit Conductor on your Mac and open it again; if it repeats with a healthy Conductor, System Events is stuck - run: killall 'System Events'"
110
+ return ""
111
+ end refusalReason
112
+
113
+ on conductorRunning()
114
+ try
115
+ tell application "System Events" to return (exists process "Conductor")
116
+ on error
117
+ return false
118
+ end try
119
+ end conductorRunning
120
+
121
+ on joinList(lst, sep)
122
+ set saved to AppleScript's text item delimiters
123
+ set AppleScript's text item delimiters to sep
124
+ set out to lst as text
125
+ set AppleScript's text item delimiters to saved
126
+ return out
127
+ end joinList
128
+
129
+ on processWindowCounts()
130
+ -- Every process whose name looks like Conductor, with its window count. If the
131
+ -- app is visibly on screen while "process Conductor" reports zero, the windows
132
+ -- belong to a process we are not addressing — and that only shows up here.
133
+ set out to {}
134
+ try
135
+ tell application "System Events"
136
+ repeat with proc in (every process whose name contains "Conductor")
137
+ set end of out to ((name of proc) & "=" & (count of windows of proc))
138
+ end repeat
139
+ end tell
140
+ end try
141
+ return out
142
+ end processWindowCounts
143
+
144
+ on menuTitles()
145
+ try
146
+ tell application "System Events" to tell process "Conductor"
147
+ return name of every menu bar item of menu bar 1
148
+ end tell
149
+ on error
150
+ return {}
151
+ end try
152
+ end menuTitles
153
+
154
+ on serverWindowCount()
155
+ -- Second opinion from the window server, because the Accessibility tree only
156
+ -- shows windows on the *current* Space: a full-screen Conductor (which owns its
157
+ -- own Space) or a Mac sitting at the lock screen reads as "no window" from AX
158
+ -- while the window very much exists — and acting on the AX answer alone once
159
+ -- quit a Conductor whose window the user had left open all along.
160
+ -- CGWindowListCopyWindowInfo(kCGWindowListOptionAll) sees every Space; owner
161
+ -- name and layer are readable without the Screen Recording grant (only window
162
+ -- *titles* are gated). Layer 0 keeps it to real windows. -1 = the probe itself
163
+ -- failed, which callers must treat as "no opinion", never as "no window".
164
+ set jxa to "ObjC.import('CoreGraphics'); const w = ObjC.deepUnwrap($.CFBridgingRelease($.CGWindowListCopyWindowInfo(0, 0))) || []; w.filter(x => x.kCGWindowOwnerName === 'Conductor' && x.kCGWindowLayer === 0).length"
165
+ try
166
+ return (do shell script "osascript -l JavaScript -e " & quoted form of jxa) as integer
167
+ on error
168
+ return -1
169
+ end try
170
+ end serverWindowCount
171
+
172
+ on screenLocked()
173
+ -- "locked" / "unlocked" / "unknown". CGSessionCopyCurrentDictionary carries
174
+ -- CGSSessionScreenIsLocked = 1 while the lock screen is up and omits the key
175
+ -- entirely when it is not — same stdlib JXA channel as serverWindowCount.
176
+ -- This matters because the lock screen is the product's home ground: sends
177
+ -- come from a phone precisely when nobody is at the Mac, so "is the lock up"
178
+ -- is the difference between a state a restart can fix and one it provably
179
+ -- makes worse (see the restart gate in activateConductor).
180
+ set jxa to "ObjC.import('CoreGraphics'); const d = ObjC.deepUnwrap($.CFBridgingRelease($.CGSessionCopyCurrentDictionary())) || {}; d.CGSSessionScreenIsLocked ? 'locked' : 'unlocked'"
181
+ try
182
+ return do shell script "osascript -l JavaScript -e " & quoted form of jxa
183
+ on error
184
+ return "unknown"
185
+ end try
186
+ end screenLocked
187
+
188
+ on windowEvidence()
189
+ -- Zero windows is a dead end without knowing what the AX tree *does* show, and
190
+ -- guessing the next thing to press has already cost a round trip (there is no
191
+ -- "New Window" item — Conductor is single-window). So the error carries the
192
+ -- evidence instead: who macOS thinks Conductor is, what its menus offer, how
193
+ -- many windows the window server counts across all Spaces (AX only sees the
194
+ -- current one), and whether the lock screen is up.
195
+ set procs to my processWindowCounts()
196
+ set titles to my menuTitles()
197
+ set ev to " [window server: " & my serverWindowCount() & "; screen: " & my screenLocked() & "]"
198
+ if (count of procs) > 0 then set ev to ev & " [processes: " & my joinList(procs, ", ") & "]"
199
+ if (count of titles) > 0 then set ev to ev & " [menus: " & my joinList(titles, ", ") & "]"
200
+ return ev
201
+ end windowEvidence
202
+
203
+ on windowFailure()
204
+ -- "" when a window is there; otherwise why not, in words the phone can act on.
205
+ -- ASCII on purpose: these reach the phone, and osascript decodes -e by the
206
+ -- caller's locale, which a LaunchAgent doesn't set. The last branch carries
207
+ -- the raw error number and text on purpose — an unrecognised refusal must
208
+ -- arrive as itself, not as the most plausible-sounding of the known causes.
209
+ set probe to my windowProbe()
210
+ set winCount to item 1 of probe
211
+ if winCount > 0 then return ""
212
+ if winCount is 0 then return "Conductor is running but exposes no window - reopen and a Dock click both drew nothing. Open or restart it on your Mac and try again." & my windowEvidence()
213
+ set refusal to my refusalReason(probe)
214
+ if refusal is not "" then return refusal
215
+ if not (my conductorRunning()) then return "Conductor is not running - the relay started it, but it did not come up in time. Try again in a few seconds."
216
+ return "couldn't read Conductor's windows (" & (item 2 of probe) & "): " & (item 3 of probe)
217
+ end windowFailure
218
+
219
+ on requireWindow()
220
+ set reason to my windowFailure()
221
+ if reason is not "" then error reason
222
+ end requireWindow
223
+
224
+ on dockClick()
225
+ -- Last resort when the "reopen" AppleEvent draws nothing: click the app's Dock
226
+ -- tile. Same intent, different delivery — the Dock raises it through the app's
227
+ -- own event loop rather than as an Apple event we send, which is what an app
228
+ -- that never wired up reopen can still answer. (There is no "New Window" menu
229
+ -- item to press: Conductor is single-window and single-instance.)
230
+ try
231
+ tell application "System Events" to tell application process "Dock"
232
+ click UI element "Conductor" of list 1
233
+ end tell
234
+ return true
235
+ on error
236
+ return false
237
+ end try
238
+ end dockClick
239
+
240
+ on restartConductor()
241
+ -- The last lever: Conductor is single-window and single-instance, so when a
242
+ -- running app answers neither reopen nor a Dock click there is nothing left to
243
+ -- press. Deliberately fire-and-forget — a cold launch is 5-10s on its own,
244
+ -- past what the phone's budget leaves once the send's own delays are counted —
245
+ -- so this returns as soon as the relaunch is under way and the caller tells the
246
+ -- phone to send again into a warm app. Gated by RELAY_ALLOW_RESTART, which the
247
+ -- relay only sets when the DB says no session is mid-turn.
248
+ try
249
+ tell application "Conductor" to quit
250
+ on error errText
251
+ return "couldn't quit Conductor to restart it: " & errText
252
+ end try
253
+ repeat with attempt from 1 to 16
254
+ if not (my conductorRunning()) then exit repeat
255
+ delay 0.25
256
+ end repeat
257
+ if my conductorRunning() then return "Conductor ignored the quit, so the relay left it alone - quit it on your Mac and try again."
258
+ try
259
+ tell application "Conductor" to activate
260
+ on error errText
261
+ return "quit Conductor but couldn't start it again: " & errText
262
+ end try
263
+ return ""
264
+ end restartConductor
265
+
266
+ on waitForWindow(attempts)
267
+ -- "reopen" is the dock-click Apple event, and for most apps that is what
268
+ -- recreates a closed window ("activate" on its own does not). Nudge a few times
269
+ -- across the wait: a cold launch can reach "process exists" long before it
270
+ -- draws anything. If reopen has drawn nothing by ~3s the app isn't answering
271
+ -- that event, so escalate to a real Dock click rather than repeating what has
272
+ -- already failed twice.
273
+ repeat with attempt from 1 to attempts
274
+ if my hasWindow() then return true
275
+ if attempt is 2 or attempt is 8 or attempt is 20 then
276
+ try
277
+ tell application "Conductor" to reopen
278
+ tell application "Conductor" to activate
279
+ end try
280
+ end if
281
+ if attempt is 12 or attempt is 28 then my dockClick()
282
+ delay 0.25
283
+ end repeat
284
+ return false
285
+ end waitForWindow
286
+
287
+ on activateConductor()
288
+ -- Conductor keeps running with every window closed (standard macOS: the red
289
+ -- button doesn't quit), and it may not be running at all. Both used to surface
290
+ -- as "Can't get window 1 of process Conductor. Invalid index." from whichever
291
+ -- handler happened to run first. So: launch-or-front it ("activate" starts it
292
+ -- when it isn't running), wait for a real window, then say what actually went
293
+ -- wrong in words the phone can act on.
294
+ -- Permissions first: a refusal reads as "no process, no window" from every
295
+ -- probe below, and waiting out the patience budget to say the wrong thing
296
+ -- helps nobody. A refusal is the one failure launching can't fix.
297
+ set firstProbe to my windowProbe()
298
+ set refusal to my refusalReason(firstProbe)
299
+ if refusal is not "" then error refusal
300
+ try
301
+ tell application "Conductor" to activate
302
+ on error errText
303
+ error "couldn't bring Conductor to the front: " & errText
304
+ end try
305
+ delay 0.4
306
+ -- Patience sized to what we're waiting for. An app that already shows a window
307
+ -- returns from waitForWindow immediately, so the short budget only ever times a
308
+ -- redraw (~4s is plenty). Zero windows gets the cold-launch budget (~9s) even
309
+ -- when the process exists: the restart below is fire-and-forget, so the *next*
310
+ -- attempt arrives while Conductor is still drawing its first window — "process
311
+ -- exists, no window yet" — and a 4s wait there quits it again mid-launch,
312
+ -- which is how one restart becomes a loop that never lets it come up. ~9s
313
+ -- still fits the 28s script budget once the send's own delays are counted.
314
+ set patience to 16
315
+ if (item 1 of firstProbe) < 1 then set patience to 36
316
+ if my waitForWindow(patience) then return
317
+ -- Running, readable, and still zero windows: reopen and the Dock click have
318
+ -- both drawn nothing, so a restart is the only thing left. "Open it on your
319
+ -- Mac" is not advice a phone can act on — that is the whole point of this app.
320
+ if (system attribute "RELAY_ALLOW_RESTART") is "1" and (item 1 of my windowProbe()) is 0 then
321
+ -- Evidence before the restart tears the state down: which processes match
322
+ -- Conductor and who owns a window is the one fact that separates "genuinely
323
+ -- windowless" (a locked or unattended Mac) from "we're addressing the wrong
324
+ -- process" — and after the relaunch it would describe the new process, not
325
+ -- the state that failed. windowFailure() already carries it; this branch
326
+ -- used to drop it, which made a repeating no-window failure undebuggable
327
+ -- from the log alone.
328
+ set ev to my windowEvidence()
329
+ set lockState to my screenLocked()
330
+ -- AX saying "no window" is not the same as there being no window: full
331
+ -- screen on another Space and the lock screen both hide a live window from
332
+ -- AX, and restarting then destroys a window the user left open (measured
333
+ -- live: exactly that loop wedged Conductor for a whole evening). Only the
334
+ -- window server can tell the difference, so a window it CAN see blocks the
335
+ -- restart — this is a human's problem, said in words, not the relay's.
336
+ if my serverWindowCount() > 0 then
337
+ if lockState is "locked" then error "The Mac is locked - Conductor's window is open behind the lock screen, where the relay can't reach it. Unlock the Mac and send again." & ev
338
+ error "Conductor's window exists but is not reachable - it is probably full screen on another Space. Take Conductor out of full screen, then send again." & ev
339
+ end if
340
+ -- No window anywhere and the lock screen up: don't restart. Every relaunch
341
+ -- fired behind the lock screen in the live incident came up windowless, and
342
+ -- the last one came up wedged — answering deep links, hanging every AX
343
+ -- read. A restart here is not a lever, it is how the wreckage happened.
344
+ if lockState is "locked" then error "The Mac is locked and Conductor has no window. Relaunching behind the lock screen is what leaves it wedged, so the relay won't - unlock the Mac and send again." & ev
345
+ set restartError to my restartConductor()
346
+ if restartError is not "" then error restartError & ev
347
+ error "Conductor was running with no window and ignored both reopen and a Dock click, so the relay restarted it. Give it a few seconds and send again." & ev
348
+ end if
349
+ my requireWindow()
350
+ end activateConductor
351
+
352
+ on webArea()
353
+ -- The webview root: the window, then four nested containers. Guarded, or a
354
+ -- window that closed mid-run reappears as an "Invalid index" from a caller
355
+ -- that has nothing to do with windows.
356
+ my requireWindow()
357
+ tell application "System Events" to tell process "Conductor"
358
+ return UI element 1 of UI element 1 of UI element 1 of UI element 1 of window 1
359
+ end tell
360
+ end webArea
361
+
362
+ on sidebarLinks()
363
+ -- The sidebar rows are AXLinks named "<repo> <title> +adds -dels". They live
364
+ -- two levels under the web area; collect them wherever they are at that depth.
365
+ set wa to my webArea()
366
+ tell application "System Events" to tell process "Conductor"
367
+ set out to {}
368
+ repeat with a in (UI elements of wa)
369
+ try
370
+ repeat with l in (UI elements of a whose role is "AXLink")
371
+ set end of out to contents of l
372
+ end repeat
373
+ repeat with b in (UI elements of a)
374
+ repeat with l in (UI elements of b whose role is "AXLink")
375
+ set end of out to contents of l
376
+ end repeat
377
+ end repeat
378
+ end try
379
+ end repeat
380
+ return out
381
+ end tell
382
+ end sidebarLinks
383
+
384
+ on findSidebarRow()
385
+ -- The workspace's own sidebar row. Only rows that are actually rendered exist
386
+ -- in the AX tree (a collapsed section has none), and the title follows
387
+ -- Conductor's precedence, so we try each candidate and require a *unique* hit —
388
+ -- anything ambiguous returns nothing rather than guessing at a neighbour.
389
+ set titles to my splitLines(system attribute "RELAY_WS_TITLES")
390
+ if (count of titles) is 0 then return missing value
391
+ set repoName to system attribute "RELAY_WS_REPO"
392
+ set rows to my sidebarLinks()
393
+ repeat with candidate in titles
394
+ if (candidate as text) is not "" then
395
+ set matches to {}
396
+ repeat with entry in rows
397
+ set row to contents of entry
398
+ -- Guarded read: the sidebar re-renders constantly (every commit
399
+ -- changes a row's "+adds -dels"), and one row that goes stale
400
+ -- mid-scan must not take the whole search down with it.
401
+ set rowName to my axName(row)
402
+ if rowName contains (candidate as text) then
403
+ if repoName is "" or rowName contains repoName then set end of matches to row
404
+ end if
405
+ end repeat
406
+ if (count of matches) is 1 then return item 1 of matches
407
+ end if
408
+ end repeat
409
+ return missing value
410
+ end findSidebarRow
411
+
412
+ on focusViaSidebar()
413
+ -- Press that row: no keystrokes at all, so nothing can be swallowed by a
414
+ -- focused field or fire a palette *command*. Being wrong is survivable here —
415
+ -- assertWorkspace re-checks before we type — and a miss falls back to the palette.
416
+ set hit to my findSidebarRow()
417
+ if hit is missing value then return false
418
+ tell application "System Events" to tell process "Conductor"
419
+ perform action "AXPress" of hit
420
+ end tell
421
+ delay 0.9
422
+ return true
423
+ end focusViaSidebar
424
+
425
+ on focusViaPalette()
426
+ tell application "System Events"
427
+ key code 53
428
+ delay 0.25
429
+ keystroke "k" using {command down}
430
+ delay 0.7
431
+ keystroke (system attribute "RELAY_WS_QUERY")
432
+ delay 0.9
433
+ key code 36
434
+ delay 1.3
435
+ end tell
436
+ end focusViaPalette
437
+
438
+ on paneAgrees()
439
+ -- Nothing in the open pane contradicts the target: a chat pane is there and its
440
+ -- header assertion passed (which is a no-op when there's no branch to check it
441
+ -- against). This is what confirms a sidebar press landed.
442
+ try
443
+ set strips to my tabGroups()
444
+ if (count of strips) is 0 then return false
445
+ my assertWorkspace(item 1 of strips)
446
+ return true
447
+ end try
448
+ return false
449
+ end paneAgrees
450
+
451
+ on atTargetWorkspace()
452
+ -- "Are we already there?" — the *strict* form of the question, so it needs a
453
+ -- branch to have been checked and not merely skipped. Worth asking first because
454
+ -- the pane header answers in ~0.5s where reading the sidebar's rows to press one
455
+ -- measured 5-7s, the single largest cost in a send: a phone sends to the same
456
+ -- workspace over and over, and Conductor stays where it was left. Safe as a
457
+ -- shortcut because it *is* the assertion the send makes before typing anyway.
458
+ if (system attribute "RELAY_WS_BRANCH") is "" then return false
459
+ return my paneAgrees()
460
+ end atTargetWorkspace
461
+
462
+ on openViaDeepLink()
463
+ -- Conductor's *own* workspace link, the one its sidebar row menu copies under
464
+ -- "Copy link" (Cmd+Shift+C): one URL carrying the workspace id and the chat id,
465
+ -- addressed the way the relay already reads them rather than by any label a
466
+ -- release can rename. It replaces the entire focus dance below - no sidebar
467
+ -- scan, no palette keystrokes, no title precedence to reproduce - and measures
468
+ -- ~0.6s against the 5-10s the row press costs on a 30-workspace sidebar.
469
+ --
470
+ -- Two things it fixes outright rather than merely speeding up. It navigates by
471
+ -- *id*, so a collapsed sidebar section (which hides the row from Accessibility
472
+ -- entirely) stops mattering; and it opens the chat tab explicitly, so a pane
473
+ -- left on a file view or a terminal - where the chat strip does not exist in
474
+ -- the AX tree at all, and every send failed with "couldn't identify the chat
475
+ -- tab strip" - comes back to the composer on its own.
476
+ --
477
+ -- Still fail-closed: the URL is fire-and-forget, so what counts as success is
478
+ -- the same pane assertion every other path here has to pass. A Conductor
479
+ -- without the route (before 0.71) ignores it silently, and a scheme macOS
480
+ -- can't resolve fails the "open" outright - both fall through to the sidebar.
481
+ set linkURL to system attribute "RELAY_WS_LINK"
482
+ if linkURL is "" then return false
483
+ try
484
+ do shell script "open " & quoted form of linkURL
485
+ on error
486
+ return false
487
+ end try
488
+ -- Bounded on purpose: this poll is also the cost of *not* having the route, and
489
+ -- the fallback below still has to run inside the same budget afterwards.
490
+ repeat with attempt from 1 to 10
491
+ delay 0.15
492
+ if my paneAgrees() then return true
493
+ end repeat
494
+ return false
495
+ end openViaDeepLink
496
+
497
+ on focusWorkspace()
498
+ if (system attribute "RELAY_WS_QUERY") is "" then return
499
+ if my openViaDeepLink() then return
500
+ if my atTargetWorkspace() then return
501
+ if my focusViaSidebar() then
502
+ if my paneAgrees() then return
503
+ end if
504
+ my focusViaPalette()
505
+ end focusWorkspace
506
+
507
+ on tabGroups()
508
+ -- Level-order search, returning every tab group at the shallowest depth that
509
+ -- has one (the chat strip and the terminal strip are siblings). Bounded: the
510
+ -- pane sits ~5 levels down, and we must never descend into the transcript.
511
+ my requireWindow()
512
+ tell application "System Events" to tell process "Conductor"
513
+ set level to {window 1}
514
+ set depth to 0
515
+ repeat while (count of level) > 0 and depth < 8
516
+ set found to {}
517
+ set nextLevel to {}
518
+ repeat with entry in level
519
+ set node to contents of entry
520
+ try
521
+ repeat with h in (UI elements of node whose role is "AXTabGroup")
522
+ set end of found to contents of h
523
+ end repeat
524
+ set nextLevel to nextLevel & (UI elements of node)
525
+ end try
526
+ end repeat
527
+ if (count of found) > 0 then return found
528
+ set level to nextLevel
529
+ set depth to depth + 1
530
+ end repeat
531
+ end tell
532
+ return {}
533
+ end tabGroups
534
+
535
+ on stripRadios(tg)
536
+ -- Every radio button in a tab strip, whether it is a direct child or sits one
537
+ -- group deep, in tree order. Both shapes are live at once: the terminal strip's
538
+ -- tabs are direct children, while a chat tab is wrapped in its own AXGroup with
539
+ -- the file-view toggle beside it as a plain child. Reading only the direct ones
540
+ -- (and stopping as soon as any turned up) is what made a chat strip look like a
541
+ -- one-tab strip called "Close file view".
542
+ tell application "System Events" to tell process "Conductor"
543
+ set acc to {}
544
+ repeat with k in (UI elements of tg)
545
+ set node to contents of k
546
+ if (role of node) is "AXRadioButton" then
547
+ set end of acc to node
548
+ else
549
+ try
550
+ repeat with r in (UI elements of node whose role is "AXRadioButton")
551
+ set end of acc to contents of r
552
+ end repeat
553
+ end try
554
+ end if
555
+ end repeat
556
+ return acc
557
+ end tell
558
+ end stripRadios
559
+
560
+ on chatTabs(tg)
561
+ -- Of those, a chat tab is the one whose accessible name is its own close action,
562
+ -- "Close chat <title>". The strip's other radio button is "Close file view",
563
+ -- which is not a chat and must not shift the indices this file addresses tabs
564
+ -- by — off by one is how a prompt lands in the wrong conversation. Falls back to
565
+ -- every radio button when none carries the prefix, so a renamed label costs the
566
+ -- filter rather than emptying the strip.
567
+ set radios to my stripRadios(tg)
568
+ set chats to {}
569
+ repeat with entry in radios
570
+ set node to contents of entry
571
+ if (my axName(node)) starts with "Close chat" then set end of chats to node
572
+ end repeat
573
+ if (count of chats) > 0 then return chats
574
+ return radios
575
+ end chatTabs
576
+
577
+ on tabLabel(t)
578
+ tell application "System Events" to tell process "Conductor"
579
+ return (name of t) as text
580
+ end tell
581
+ end tabLabel
582
+
583
+ on pickChatStrip(strips, wantCount, wantTitle)
584
+ -- Score each candidate strip: a label match outweighs a tab-count match, and
585
+ -- a tie means we cannot tell the chat strip from the terminal strip.
586
+ set best to missing value
587
+ set bestTabs to {}
588
+ set bestScore to 0
589
+ set tied to false
590
+ repeat with entry in strips
591
+ set tg to contents of entry
592
+ set strip to my chatTabs(tg)
593
+ set score to 0
594
+ if wantCount > 0 and (count of strip) is wantCount then set score to score + 1
595
+ if wantTitle is not "" then
596
+ repeat with t in strip
597
+ if (my tabLabel(t)) contains wantTitle then
598
+ set score to score + 2
599
+ exit repeat
600
+ end if
601
+ end repeat
602
+ end if
603
+ if score > bestScore then
604
+ set bestScore to score
605
+ set best to tg
606
+ set bestTabs to strip
607
+ set tied to false
608
+ else if score is bestScore and score > 0 then
609
+ set tied to true
610
+ end if
611
+ end repeat
612
+ if bestScore is 0 then error "couldn't identify the chat tab strip"
613
+ if tied then error "can't tell which tab strip holds the target chat"
614
+ return {best, bestTabs}
615
+ end pickChatStrip
616
+
617
+ on lastPathSegment(s)
618
+ set saved to AppleScript's text item delimiters
619
+ set AppleScript's text item delimiters to "/"
620
+ set parts to text items of s
621
+ set AppleScript's text item delimiters to saved
622
+ return item -1 of parts
623
+ end lastPathSegment
624
+
625
+ on paneLabels(tg, wantRole)
626
+ -- Kept out of the caller's scope on purpose: inside a System Events tell,
627
+ -- ordinary-looking names (tabs, count) resolve as app terms instead of vars.
628
+ tell application "System Events" to tell process "Conductor"
629
+ set pane to value of attribute "AXParent" of tg
630
+ return (name of (UI elements of pane whose role is wantRole))
631
+ end tell
632
+ end paneLabels
633
+
634
+ on anyContains(haystack, needle)
635
+ repeat with entry in haystack
636
+ try
637
+ if (entry as text) contains needle then return true
638
+ end try
639
+ end repeat
640
+ return false
641
+ end anyContains
642
+
643
+ on assertWorkspace(tg)
644
+ -- The pane holding the chat strip also labels the open workspace: an
645
+ -- AXStaticText with the branch (sans owner prefix) and a repo popup button.
646
+ set wantBranch to system attribute "RELAY_WS_BRANCH"
647
+ if wantBranch is "" then return
648
+ set tail to my lastPathSegment(wantBranch)
649
+ if not (my anyContains(my paneLabels(tg, "AXStaticText"), tail)) then
650
+ error "the palette didn't land on " & tail
651
+ end if
652
+ set wantRepo to system attribute "RELAY_WS_REPO"
653
+ if wantRepo is not "" then
654
+ if not (my anyContains(my paneLabels(tg, "AXPopUpButton"), wantRepo)) then
655
+ error "the palette didn't land in " & wantRepo
656
+ end if
657
+ end if
658
+ end assertWorkspace
659
+
660
+ on normalizeNewlines(s)
661
+ -- "do shell script" hands back CR-delimited text; the composer reads back LF.
662
+ -- Without this the verification below never matches a multi-line prompt.
663
+ set saved to AppleScript's text item delimiters
664
+ set AppleScript's text item delimiters to return
665
+ set parts to text items of s
666
+ set AppleScript's text item delimiters to linefeed
667
+ set joined to parts as text
668
+ set AppleScript's text item delimiters to saved
669
+ return joined
670
+ end normalizeNewlines
671
+
672
+ on fillComposer(promptText)
673
+ -- Write the prompt straight into the composer's AXTextArea instead of
674
+ -- stashing the clipboard, pressing Cmd+L and pasting. AXFocused and AXValue
675
+ -- are both settable, so this needs no keystrokes and no clipboard hijack.
676
+ -- Returns false (→ caller falls back to pasting) if anything looks off, but
677
+ -- *clears whatever it wrote first*: leaving half a prompt behind would make
678
+ -- the fallback paste append to it and send a garbled prompt.
679
+ if promptText is "" then return false
680
+ set strips to my tabGroups()
681
+ if (count of strips) is 0 then return false
682
+ try
683
+ tell application "System Events" to tell process "Conductor"
684
+ set pane to value of attribute "AXParent" of (item 1 of strips)
685
+ set composerBox to item 1 of (UI elements of pane whose name is "composer")
686
+ set textBox to item 1 of (UI elements of composerBox whose role is "AXTextArea")
687
+ set value of attribute "AXFocused" of textBox to true
688
+ set value of textBox to promptText
689
+ delay 0.25
690
+ if ((value of textBox) as text) does not contain promptText then
691
+ set value of textBox to ""
692
+ return false
693
+ end if
694
+ end tell
695
+ on error
696
+ try
697
+ my clearComposer()
698
+ end try
699
+ return false
700
+ end try
701
+ return true
702
+ end fillComposer
703
+
704
+ on clearComposer()
705
+ set strips to my tabGroups()
706
+ if (count of strips) is 0 then return
707
+ tell application "System Events" to tell process "Conductor"
708
+ set pane to value of attribute "AXParent" of (item 1 of strips)
709
+ set composerBox to item 1 of (UI elements of pane whose name is "composer")
710
+ set value of (item 1 of (UI elements of composerBox whose role is "AXTextArea")) to ""
711
+ end tell
712
+ end clearComposer
713
+
714
+ on pasteComposer()
715
+ -- Fallback for when the composer isn't reachable: Cmd+L focuses it (after the
716
+ -- palette, focus sits on a button, not the text box), then paste.
717
+ tell application "System Events"
718
+ set the clipboard to (do shell script "cat" & " " & quoted form of (system attribute "RELAY_PROMPT_FILE"))
719
+ keystroke "l" using {command down}
720
+ delay 0.3
721
+ keystroke "v" using {command down}
722
+ delay 0.15
723
+ end tell
724
+ end pasteComposer
725
+
726
+ on selectChatTab()
727
+ set wantIndex to (system attribute "RELAY_TAB_INDEX") as integer
728
+ if wantIndex is 0 then return
729
+ set wantCount to (system attribute "RELAY_TAB_COUNT") as integer
730
+ set wantTitle to system attribute "RELAY_TAB_TITLE"
731
+ set strips to my tabGroups()
732
+ if (count of strips) is 0 then
733
+ -- A lone chat has no ambiguity to resolve; more than one and we must not guess.
734
+ if wantCount <= 1 then return
735
+ error "couldn't find the chat tab strip"
736
+ end if
737
+ -- Assert the workspace first: every strip lives in the same pane, so this
738
+ -- reports "wrong workspace" rather than a confusing "no chat strip".
739
+ my assertWorkspace(item 1 of strips)
740
+ set picked to my pickChatStrip(strips, wantCount, wantTitle)
741
+ set tabs to item 2 of picked
742
+ tell application "System Events" to tell process "Conductor"
743
+ set target to missing value
744
+ if wantIndex <= (count of tabs) then
745
+ set candidate to contents of (item wantIndex of tabs)
746
+ if wantTitle is "" or (name of candidate) contains wantTitle then set target to candidate
747
+ end if
748
+ if target is missing value and wantTitle is not "" then
749
+ repeat with t in tabs
750
+ if (name of t) contains wantTitle then
751
+ if target is not missing value then error "several chat tabs match " & wantTitle
752
+ set target to contents of t
753
+ end if
754
+ end repeat
755
+ end if
756
+ if target is missing value then error "chat tab " & wantIndex & " not found"
757
+ if (value of target) is not true then
758
+ perform action "AXPress" of target
759
+ delay 0.5
760
+ end if
761
+ if (value of target) is not true then error "couldn't switch to the target chat tab"
762
+ end tell
763
+ end selectChatTab
764
+
765
+ on composerControls()
766
+ set strips to my tabGroups()
767
+ if (count of strips) is 0 then error "couldn't find the composer"
768
+ tell application "System Events" to tell process "Conductor"
769
+ set pane to value of attribute "AXParent" of (item 1 of strips)
770
+ set composerBox to item 1 of (UI elements of pane whose name is "composer")
771
+ set out to {}
772
+ repeat with e in (UI elements of composerBox)
773
+ set end of out to contents of e
774
+ repeat with e2 in (UI elements of e)
775
+ set end of out to contents of e2
776
+ end repeat
777
+ end repeat
778
+ return out
779
+ end tell
780
+ end composerControls
781
+
782
+ on controlNamed(wanted)
783
+ repeat with entry in my composerControls()
784
+ set c to contents of entry
785
+ if my tabLabel(c) is wanted then return c
786
+ end repeat
787
+ return missing value
788
+ end controlNamed
789
+
790
+ on effortButton()
791
+ -- The effort control is a button whose *label is its current value*, so it is
792
+ -- identified by that label rather than a stable name.
793
+ set levels to {"Low", "Medium", "High", "Extra high", "Max", "Ultracode"}
794
+ repeat with entry in my composerControls()
795
+ set c to contents of entry
796
+ if my tabLabel(c) is in levels then return c
797
+ end repeat
798
+ return missing value
799
+ end effortButton
800
+
801
+ on setEffort(wanted)
802
+ -- Pressing cycles Low → Medium → High → Extra high → Max → Ultracode → wrap,
803
+ -- so step around the ring at most one full turn and confirm the label landed.
804
+ set btn to my effortButton()
805
+ if btn is missing value then error "couldn't find the effort control"
806
+ repeat 7 times
807
+ if my tabLabel(btn) is wanted then return
808
+ tell application "System Events" to tell process "Conductor"
809
+ perform action "AXPress" of btn
810
+ end tell
811
+ delay 0.35
812
+ set btn to my effortButton()
813
+ if btn is missing value then error "the effort control vanished mid-cycle"
814
+ end repeat
815
+ error "couldn't set effort to " & wanted
816
+ end setEffort
817
+
818
+ on setPlan(wanted)
819
+ set box to my controlNamed("Plan")
820
+ if box is missing value then error "couldn't find the Plan toggle"
821
+ tell application "System Events" to tell process "Conductor"
822
+ set current to ((value of box) as text)
823
+ if (wanted is "1" and current is "0") or (wanted is "0" and current is not "0") then
824
+ perform action "AXPress" of box
825
+ delay 0.4
826
+ if ((value of box) as text) is current then error "the Plan toggle didn't change"
827
+ end if
828
+ end tell
829
+ end setPlan
830
+
831
+ on pressFast()
832
+ -- Fast has no AX state to read (its label is always "Fast"), so the caller
833
+ -- decides whether a press is needed and re-checks the DB afterwards.
834
+ set btn to my controlNamed("Fast")
835
+ if btn is missing value then error "this model has no Fast toggle"
836
+ tell application "System Events" to tell process "Conductor"
837
+ perform action "AXPress" of btn
838
+ end tell
839
+ delay 0.4
840
+ end pressFast
841
+
842
+ on firstLine(s)
843
+ set saved to AppleScript's text item delimiters
844
+ set AppleScript's text item delimiters to linefeed
845
+ set parts to text items of s
846
+ set AppleScript's text item delimiters to saved
847
+ return item 1 of parts
848
+ end firstLine
849
+
850
+ on setModel(wanted)
851
+ set popup to missing value
852
+ repeat with entry in my composerControls()
853
+ set c to contents of entry
854
+ if my tabLabel(c) contains "Change agent" then set popup to c
855
+ end repeat
856
+ if popup is missing value then error "couldn't find the model picker"
857
+ if (my tabLabel(popup)) contains ("(" & wanted & ")") then return
858
+ tell application "System Events" to tell process "Conductor"
859
+ perform action "AXPress" of popup
860
+ end tell
861
+ delay 1.0
862
+ -- Menu labels carry badges ("Opus 5 NEW"), so an exact match is preferred but a
863
+ -- prefix match is accepted — except when it is ambiguous ("Sonnet 4.6" would
864
+ -- otherwise also match "Sonnet 4.6 1M"), which must fail rather than guess.
865
+ set chosen to missing value
866
+ set loose to {}
867
+ set wa to my webArea()
868
+ tell application "System Events" to tell process "Conductor"
869
+ repeat with m in (UI elements of wa whose role is "AXMenu")
870
+ repeat with mi in (UI elements of m whose role is "AXMenuItem")
871
+ set label to my firstLine(my tabLabel(mi))
872
+ if label is wanted then
873
+ set chosen to contents of mi
874
+ else if label starts with wanted then
875
+ set end of loose to contents of mi
876
+ end if
877
+ end repeat
878
+ end repeat
879
+ end tell
880
+ if chosen is missing value and (count of loose) is 1 then set chosen to item 1 of loose
881
+ if chosen is missing value then
882
+ tell application "System Events" to key code 53
883
+ if (count of loose) > 1 then error "several models match " & wanted
884
+ error "no model named " & wanted
885
+ end if
886
+ tell application "System Events" to tell process "Conductor"
887
+ perform action "AXPress" of chosen
888
+ end tell
889
+ delay 0.8
890
+ set popup to missing value
891
+ repeat with entry in my composerControls()
892
+ set c to contents of entry
893
+ if my tabLabel(c) contains "Change agent" then set popup to c
894
+ end repeat
895
+ if popup is missing value then error "the model picker vanished"
896
+ if (my tabLabel(popup)) does not contain ("(" & wanted & ")") then error "the model didn't switch to " & wanted
897
+ end setModel
898
+
899
+ on listModels()
900
+ -- Enumerate the picker rather than hard-coding a model list that would rot on
901
+ -- every Conductor release. Opens the menu, reads the labels, closes it again.
902
+ set popup to missing value
903
+ repeat with entry in my composerControls()
904
+ set c to contents of entry
905
+ if my tabLabel(c) contains "Change agent" then set popup to c
906
+ end repeat
907
+ if popup is missing value then error "couldn't find the model picker"
908
+ tell application "System Events" to tell process "Conductor"
909
+ perform action "AXPress" of popup
910
+ end tell
911
+ delay 1.0
912
+ set labels to {}
913
+ set wa to my webArea()
914
+ tell application "System Events" to tell process "Conductor"
915
+ repeat with m in (UI elements of wa whose role is "AXMenu")
916
+ repeat with mi in (UI elements of m whose role is "AXMenuItem")
917
+ set end of labels to my firstLine(my tabLabel(mi))
918
+ end repeat
919
+ end repeat
920
+ end tell
921
+ tell application "System Events" to key code 53
922
+ set saved to AppleScript's text item delimiters
923
+ set AppleScript's text item delimiters to linefeed
924
+ set joined to labels as text
925
+ set AppleScript's text item delimiters to saved
926
+ return joined
927
+ end listModels
928
+
929
+ on applyAgentOptions()
930
+ set wantEffort to system attribute "RELAY_SET_EFFORT"
931
+ set wantPlan to system attribute "RELAY_SET_PLAN"
932
+ set wantFast to system attribute "RELAY_SET_FAST"
933
+ set wantModel to system attribute "RELAY_SET_MODEL"
934
+ if wantModel is not "" then my setModel(wantModel)
935
+ if wantEffort is not "" then my setEffort(wantEffort)
936
+ if wantPlan is not "" then my setPlan(wantPlan)
937
+ if wantFast is "1" then my pressFast()
938
+ end applyAgentOptions
939
+
940
+ on axRole(el)
941
+ tell application "System Events" to tell process "Conductor"
942
+ try
943
+ return role of el
944
+ on error
945
+ return ""
946
+ end try
947
+ end tell
948
+ end axRole
949
+
950
+ on axKids(el)
951
+ tell application "System Events" to tell process "Conductor"
952
+ try
953
+ return UI elements of el
954
+ on error
955
+ return {}
956
+ end try
957
+ end tell
958
+ end axKids
959
+
960
+ on menusUnder(root, maxDepth)
961
+ -- Every AXMenu at or below root, breadth-first and bounded. The status
962
+ -- submenu opens *nested inside* the row menu rather than beside it, so a
963
+ -- search that stops at the first hit (the way setModel scans the web area's
964
+ -- direct children) never sees it.
965
+ set level to {root}
966
+ set found to {}
967
+ set depth to 0
968
+ repeat while (count of level) > 0 and depth < maxDepth
969
+ set nextLevel to {}
970
+ repeat with entry in level
971
+ set node to contents of entry
972
+ if (my axRole(node)) is "AXMenu" then set end of found to node
973
+ set nextLevel to nextLevel & (my axKids(node))
974
+ end repeat
975
+ set level to nextLevel
976
+ set depth to depth + 1
977
+ end repeat
978
+ return found
979
+ end menusUnder
980
+
981
+ on axName(el)
982
+ -- Guarded: a menu re-renders under us, and reading the name of an element that
983
+ -- has since gone throws an object-specifier error from wherever we happened to be.
984
+ tell application "System Events" to tell process "Conductor"
985
+ try
986
+ set n to name of el
987
+ if n is missing value then return ""
988
+ return n as text
989
+ on error
990
+ return ""
991
+ end try
992
+ end tell
993
+ end axName
994
+
995
+ on menuItemNamed(m, wanted)
996
+ repeat with mi in (my axKids(m))
997
+ set node to contents of mi
998
+ if (my axRole(node)) is "AXMenuItem" and (my axName(node)) is wanted then return node
999
+ end repeat
1000
+ return missing value
1001
+ end menuItemNamed
1002
+
1003
+ on waitForMenuWith(root, maxDepth, itemName, attempts)
1004
+ -- Identify the menu by an item it *contains*, never as "the first AXMenu on
1005
+ -- screen": a model picker left open, or a menu from the run before, is also an
1006
+ -- AXMenu, and picking it produced "no Set status item" on a menu that had never
1007
+ -- been the workspace's. A menu is drawn by the webview, not by us, so how long
1008
+ -- it takes varies with what Conductor is busy doing — poll rather than guess
1009
+ -- one delay; the common case costs a single sweep.
1010
+ repeat with i from 1 to attempts
1011
+ repeat with entry in my menusUnder(root, maxDepth)
1012
+ set node to contents of entry
1013
+ if (my menuItemNamed(node, itemName)) is not missing value then return node
1014
+ end repeat
1015
+ delay 0.5
1016
+ end repeat
1017
+ return missing value
1018
+ end waitForMenuWith
1019
+
1020
+ on dismissMenus()
1021
+ -- Two escapes: one for the submenu, one for the row menu. Leaving either open
1022
+ -- would swallow the next run's keystrokes.
1023
+ tell application "System Events"
1024
+ key code 53
1025
+ delay 0.25
1026
+ key code 53
1027
+ end tell
1028
+ end dismissMenus
1029
+
1030
+ on setWorkspaceStatus()
1031
+ -- Conductor has no menu-bar or palette command for this, so the only lever is
1032
+ -- the sidebar row's own context menu (Mark as unread / Pin / Set status /
1033
+ -- Rename / Copy link / Archive). Right-clicking the row needs no focus change,
1034
+ -- so unlike a send this never disturbs which workspace is on screen.
1035
+ set wanted to system attribute "RELAY_SET_STATUS"
1036
+ if wanted is "" then error "no status requested"
1037
+ set theRow to my findSidebarRow()
1038
+ if theRow is missing value then
1039
+ error "couldn't find this workspace in the sidebar — a collapsed section hides its row from Accessibility"
1040
+ end if
1041
+ -- Scroll it into view first. A row that exists in the AX tree but sits outside
1042
+ -- the sidebar's visible strip accepts AXShowMenu and draws nothing — which is
1043
+ -- exactly what happens right after a status change moves it to another group.
1044
+ tell application "System Events" to tell process "Conductor"
1045
+ try
1046
+ perform action "AXScrollToVisible" of theRow
1047
+ end try
1048
+ end tell
1049
+ delay 0.4
1050
+ -- Clear anything already open (a picker, or a menu a previous run left behind)
1051
+ -- so the sweep below can only match the one this right-click draws.
1052
+ tell application "System Events" to key code 53
1053
+ delay 0.3
1054
+ tell application "System Events" to tell process "Conductor"
1055
+ perform action "AXShowMenu" of theRow
1056
+ end tell
1057
+ -- Depth 4, not the whole tree: the row menu is a portal near the top of the web
1058
+ -- area, and the *transcript* hangs off that same root — a deep sweep walks every
1059
+ -- message bubble and costs more than the rest of this write put together (it
1060
+ -- grows with the conversation, so it degrades as you use the app).
1061
+ set rowMenu to my waitForMenuWith(my webArea(), 4, "Set status", 6)
1062
+ if rowMenu is missing value then
1063
+ my dismissMenus()
1064
+ error "the workspace's menu didn't open — or it no longer offers Set status"
1065
+ end if
1066
+ set statusItem to my menuItemNamed(rowMenu, "Set status")
1067
+ tell application "System Events" to tell process "Conductor"
1068
+ perform action "AXPress" of statusItem
1069
+ end tell
1070
+ -- The submenu is an AXMenu nested a few levels *inside* the row menu (Conductor
1071
+ -- expands it in place rather than opening a sibling), so search from that menu —
1072
+ -- searching the web area again would re-walk the world. Two things make this a
1073
+ -- poll rather than a read: the row-menu handle goes stale across the expansion
1074
+ -- (it reports no children instead of erroring), so it is re-found each pass; and
1075
+ -- the submenu's *items* arrive a beat after the submenu itself, so the thing we
1076
+ -- wait for is the wanted status, not the container that will hold it.
1077
+ set subMenu to missing value
1078
+ repeat with attempt from 1 to 8
1079
+ set liveMenu to my waitForMenuWith(my webArea(), 4, "Set status", 1)
1080
+ if liveMenu is not missing value then
1081
+ set subMenu to my waitForMenuWith(liveMenu, 5, wanted, 1)
1082
+ end if
1083
+ if subMenu is not missing value then exit repeat
1084
+ delay 0.4
1085
+ end repeat
1086
+ if subMenu is missing value then
1087
+ my dismissMenus()
1088
+ error "Conductor never offered a status called " & wanted
1089
+ end if
1090
+ set choice to my menuItemNamed(subMenu, wanted)
1091
+ tell application "System Events" to tell process "Conductor"
1092
+ perform action "AXPress" of choice
1093
+ end tell
1094
+ delay 0.6
1095
+ end setWorkspaceStatus