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