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