conductor-remote 1.34.2 → 1.34.3

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