conductor-remote 1.25.0 → 1.26.0

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.
@@ -32,24 +32,568 @@ export class SidecarActuator {
32
32
  }
33
33
  }
34
34
  }
35
- // AppleScript steps that focus a workspace via Conductor's command palette
36
- // (Esc Cmd+K branch Enter). The branch is read from RELAY_WS_QUERY at run
37
- // time to dodge AppleScript escaping; the timing delays are load-bearing.
38
- const FOCUS_WORKSPACE_STEPS = `
39
- key code 53
40
- delay 0.25
41
- keystroke "k" using {command down}
42
- delay 0.7
43
- keystroke (system attribute "RELAY_WS_QUERY")
35
+ /**
36
+ * AppleScript handlers that pin down *which chat* the prompt goes to.
37
+ *
38
+ * The palette (Cmd+K) only gets us to the right workspace — a workspace holds
39
+ * several chats, and Conductor keeps typing in whichever tab is already active.
40
+ * So a send addressed to a non-active chat would land in the wrong agent.
41
+ *
42
+ * Conductor's webview exposes the whole tree through macOS Accessibility. The
43
+ * chat strip is an AXTabGroup whose AXRadioButtons are the tabs (`AXValue`
44
+ * marks the selected one, `AXPress` switches to it — it does *not* close the
45
+ * chat). The strip's order matches `reads.listSessions` (created_at ASC), so
46
+ * the caller addresses a tab by 1-based index and we cross-check the label.
47
+ *
48
+ * Two traps this has to survive:
49
+ * - **The terminal panel is an AXTabGroup too** (radio buttons named Setup /
50
+ * Run / Terminal 1), a sibling of the chat strip in the same pane. Picking
51
+ * "the first tab group" would sometimes press a terminal tab, so we *score*
52
+ * the candidates on tab count + label and refuse to act on a tie.
53
+ * - **The palette can land on the wrong workspace** (a loose query matches a
54
+ * command; a deleted branch opens a modal). The pane header carries the
55
+ * branch and repo, so we read them back and bail if they disagree.
56
+ *
57
+ * Target is read from RELAY_TAB_{INDEX,COUNT,TITLE} + RELAY_WS_{BRANCH,REPO};
58
+ * index 0 disables the step. Every failure path errors out so the caller aborts
59
+ * *before* pasting — landing in the wrong chat is worse than not sending.
60
+ */
61
+ const SELECT_CHAT_TAB_HANDLERS = `
62
+ on splitLines(s)
63
+ set saved to AppleScript's text item delimiters
64
+ set AppleScript's text item delimiters to linefeed
65
+ set parts to text items of s
66
+ set AppleScript's text item delimiters to saved
67
+ return parts
68
+ end splitLines
69
+
70
+ on sidebarLinks()
71
+ -- The sidebar rows are AXLinks named "<repo> <title> +adds -dels". They live
72
+ -- two levels under the web area; collect them wherever they are at that depth.
73
+ tell application "System Events" to tell process "Conductor"
74
+ set wa to UI element 1 of UI element 1 of UI element 1 of UI element 1 of window 1
75
+ set out to {}
76
+ repeat with a in (UI elements of wa)
77
+ try
78
+ repeat with l in (UI elements of a whose role is "AXLink")
79
+ set end of out to contents of l
80
+ end repeat
81
+ repeat with b in (UI elements of a)
82
+ repeat with l in (UI elements of b whose role is "AXLink")
83
+ set end of out to contents of l
84
+ end repeat
85
+ end repeat
86
+ end try
87
+ end repeat
88
+ return out
89
+ end tell
90
+ end sidebarLinks
91
+
92
+ on focusViaSidebar()
93
+ -- Press the workspace's sidebar row: no keystrokes at all, so nothing can be
94
+ -- swallowed by a focused field or fire a palette *command*. Only rows that are
95
+ -- actually rendered exist in the AX tree (a collapsed section has none), and
96
+ -- the title precedence is Conductor's, so we try each candidate and require a
97
+ -- unique hit — anything ambiguous falls back to the palette. Being wrong is
98
+ -- survivable here: assertWorkspace re-checks before we type.
99
+ set titles to my splitLines(system attribute "RELAY_WS_TITLES")
100
+ if (count of titles) is 0 then return false
101
+ set repoName to system attribute "RELAY_WS_REPO"
102
+ set rows to my sidebarLinks()
103
+ set hit to missing value
104
+ repeat with candidate in titles
105
+ if (candidate as text) is not "" then
106
+ set matches to {}
107
+ repeat with entry in rows
108
+ set row to contents of entry
109
+ set rowName to my tabLabel(row)
110
+ if rowName contains (candidate as text) then
111
+ if repoName is "" or rowName contains repoName then set end of matches to row
112
+ end if
113
+ end repeat
114
+ if (count of matches) is 1 then
115
+ set hit to item 1 of matches
116
+ exit repeat
117
+ end if
118
+ end if
119
+ end repeat
120
+ if hit is missing value then return false
121
+ tell application "System Events" to tell process "Conductor"
122
+ perform action "AXPress" of hit
123
+ end tell
44
124
  delay 0.9
45
- key code 36
46
- delay 1.3`;
125
+ return true
126
+ end focusViaSidebar
127
+
128
+ on focusViaPalette()
129
+ tell application "System Events"
130
+ key code 53
131
+ delay 0.25
132
+ keystroke "k" using {command down}
133
+ delay 0.7
134
+ keystroke (system attribute "RELAY_WS_QUERY")
135
+ delay 0.9
136
+ key code 36
137
+ delay 1.3
138
+ end tell
139
+ end focusViaPalette
140
+
141
+ on focusWorkspace()
142
+ if (system attribute "RELAY_WS_QUERY") is "" then return
143
+ if my focusViaSidebar() then
144
+ try
145
+ set strips to my tabGroups()
146
+ if (count of strips) > 0 then
147
+ my assertWorkspace(item 1 of strips)
148
+ return
149
+ end if
150
+ end try
151
+ end if
152
+ my focusViaPalette()
153
+ end focusWorkspace
154
+
155
+ on tabGroups()
156
+ -- Level-order search, returning every tab group at the shallowest depth that
157
+ -- has one (the chat strip and the terminal strip are siblings). Bounded: the
158
+ -- pane sits ~5 levels down, and we must never descend into the transcript.
159
+ tell application "System Events" to tell process "Conductor"
160
+ set level to {window 1}
161
+ set depth to 0
162
+ repeat while (count of level) > 0 and depth < 8
163
+ set found to {}
164
+ set nextLevel to {}
165
+ repeat with entry in level
166
+ set node to contents of entry
167
+ try
168
+ repeat with h in (UI elements of node whose role is "AXTabGroup")
169
+ set end of found to contents of h
170
+ end repeat
171
+ set nextLevel to nextLevel & (UI elements of node)
172
+ end try
173
+ end repeat
174
+ if (count of found) > 0 then return found
175
+ set level to nextLevel
176
+ set depth to depth + 1
177
+ end repeat
178
+ end tell
179
+ return {}
180
+ end tabGroups
181
+
182
+ on chatTabs(tg)
183
+ tell application "System Events" to tell process "Conductor"
184
+ set direct to (UI elements of tg whose role is "AXRadioButton")
185
+ if (count of direct) > 0 then return direct
186
+ set nested to {}
187
+ repeat with g in (UI elements of tg)
188
+ repeat with r in (UI elements of g whose role is "AXRadioButton")
189
+ set end of nested to contents of r
190
+ end repeat
191
+ end repeat
192
+ return nested
193
+ end tell
194
+ end chatTabs
195
+
196
+ on tabLabel(t)
197
+ tell application "System Events" to tell process "Conductor"
198
+ return (name of t) as text
199
+ end tell
200
+ end tabLabel
201
+
202
+ on pickChatStrip(strips, wantCount, wantTitle)
203
+ -- Score each candidate strip: a label match outweighs a tab-count match, and
204
+ -- a tie means we cannot tell the chat strip from the terminal strip.
205
+ set best to missing value
206
+ set bestTabs to {}
207
+ set bestScore to 0
208
+ set tied to false
209
+ repeat with entry in strips
210
+ set tg to contents of entry
211
+ set strip to my chatTabs(tg)
212
+ set score to 0
213
+ if wantCount > 0 and (count of strip) is wantCount then set score to score + 1
214
+ if wantTitle is not "" then
215
+ repeat with t in strip
216
+ if (my tabLabel(t)) contains wantTitle then
217
+ set score to score + 2
218
+ exit repeat
219
+ end if
220
+ end repeat
221
+ end if
222
+ if score > bestScore then
223
+ set bestScore to score
224
+ set best to tg
225
+ set bestTabs to strip
226
+ set tied to false
227
+ else if score is bestScore and score > 0 then
228
+ set tied to true
229
+ end if
230
+ end repeat
231
+ if bestScore is 0 then error "couldn't identify the chat tab strip"
232
+ if tied then error "can't tell which tab strip holds the target chat"
233
+ return {best, bestTabs}
234
+ end pickChatStrip
235
+
236
+ on lastPathSegment(s)
237
+ set saved to AppleScript's text item delimiters
238
+ set AppleScript's text item delimiters to "/"
239
+ set parts to text items of s
240
+ set AppleScript's text item delimiters to saved
241
+ return item -1 of parts
242
+ end lastPathSegment
243
+
244
+ on paneLabels(tg, wantRole)
245
+ -- Kept out of the caller's scope on purpose: inside a System Events tell,
246
+ -- ordinary-looking names (tabs, count) resolve as app terms instead of vars.
247
+ tell application "System Events" to tell process "Conductor"
248
+ set pane to value of attribute "AXParent" of tg
249
+ return (name of (UI elements of pane whose role is wantRole))
250
+ end tell
251
+ end paneLabels
252
+
253
+ on anyContains(haystack, needle)
254
+ repeat with entry in haystack
255
+ try
256
+ if (entry as text) contains needle then return true
257
+ end try
258
+ end repeat
259
+ return false
260
+ end anyContains
261
+
262
+ on assertWorkspace(tg)
263
+ -- The pane holding the chat strip also labels the open workspace: an
264
+ -- AXStaticText with the branch (sans owner prefix) and a repo popup button.
265
+ set wantBranch to system attribute "RELAY_WS_BRANCH"
266
+ if wantBranch is "" then return
267
+ set tail to my lastPathSegment(wantBranch)
268
+ if not (my anyContains(my paneLabels(tg, "AXStaticText"), tail)) then
269
+ error "the palette didn't land on " & tail
270
+ end if
271
+ set wantRepo to system attribute "RELAY_WS_REPO"
272
+ if wantRepo is not "" then
273
+ if not (my anyContains(my paneLabels(tg, "AXPopUpButton"), wantRepo)) then
274
+ error "the palette didn't land in " & wantRepo
275
+ end if
276
+ end if
277
+ end assertWorkspace
278
+
279
+ on normalizeNewlines(s)
280
+ -- "do shell script" hands back CR-delimited text; the composer reads back LF.
281
+ -- Without this the verification below never matches a multi-line prompt.
282
+ set saved to AppleScript's text item delimiters
283
+ set AppleScript's text item delimiters to return
284
+ set parts to text items of s
285
+ set AppleScript's text item delimiters to linefeed
286
+ set joined to parts as text
287
+ set AppleScript's text item delimiters to saved
288
+ return joined
289
+ end normalizeNewlines
290
+
291
+ on fillComposer(promptText)
292
+ -- Write the prompt straight into the composer's AXTextArea instead of
293
+ -- stashing the clipboard, pressing Cmd+L and pasting. AXFocused and AXValue
294
+ -- are both settable, so this needs no keystrokes and no clipboard hijack.
295
+ -- Returns false (→ caller falls back to pasting) if anything looks off, but
296
+ -- *clears whatever it wrote first*: leaving half a prompt behind would make
297
+ -- the fallback paste append to it and send a garbled prompt.
298
+ if promptText is "" then return false
299
+ set strips to my tabGroups()
300
+ if (count of strips) is 0 then return false
301
+ try
302
+ tell application "System Events" to tell process "Conductor"
303
+ set pane to value of attribute "AXParent" of (item 1 of strips)
304
+ set composerBox to item 1 of (UI elements of pane whose name is "composer")
305
+ set textBox to item 1 of (UI elements of composerBox whose role is "AXTextArea")
306
+ set value of attribute "AXFocused" of textBox to true
307
+ set value of textBox to promptText
308
+ delay 0.25
309
+ if ((value of textBox) as text) does not contain promptText then
310
+ set value of textBox to ""
311
+ return false
312
+ end if
313
+ end tell
314
+ on error
315
+ try
316
+ my clearComposer()
317
+ end try
318
+ return false
319
+ end try
320
+ return true
321
+ end fillComposer
322
+
323
+ on clearComposer()
324
+ set strips to my tabGroups()
325
+ if (count of strips) is 0 then return
326
+ tell application "System Events" to tell process "Conductor"
327
+ set pane to value of attribute "AXParent" of (item 1 of strips)
328
+ set composerBox to item 1 of (UI elements of pane whose name is "composer")
329
+ set value of (item 1 of (UI elements of composerBox whose role is "AXTextArea")) to ""
330
+ end tell
331
+ end clearComposer
332
+
333
+ on pasteComposer()
334
+ -- Fallback for when the composer isn't reachable: Cmd+L focuses it (after the
335
+ -- palette, focus sits on a button, not the text box), then paste.
336
+ tell application "System Events"
337
+ set the clipboard to (do shell script "cat" & " " & quoted form of (system attribute "RELAY_PROMPT_FILE"))
338
+ keystroke "l" using {command down}
339
+ delay 0.3
340
+ keystroke "v" using {command down}
341
+ delay 0.15
342
+ end tell
343
+ end pasteComposer
344
+
345
+ on selectChatTab()
346
+ set wantIndex to (system attribute "RELAY_TAB_INDEX") as integer
347
+ if wantIndex is 0 then return
348
+ set wantCount to (system attribute "RELAY_TAB_COUNT") as integer
349
+ set wantTitle to system attribute "RELAY_TAB_TITLE"
350
+ set strips to my tabGroups()
351
+ if (count of strips) is 0 then
352
+ -- A lone chat has no ambiguity to resolve; more than one and we must not guess.
353
+ if wantCount <= 1 then return
354
+ error "couldn't find the chat tab strip"
355
+ end if
356
+ -- Assert the workspace first: every strip lives in the same pane, so this
357
+ -- reports "wrong workspace" rather than a confusing "no chat strip".
358
+ my assertWorkspace(item 1 of strips)
359
+ set picked to my pickChatStrip(strips, wantCount, wantTitle)
360
+ set tabs to item 2 of picked
361
+ tell application "System Events" to tell process "Conductor"
362
+ set target to missing value
363
+ if wantIndex <= (count of tabs) then
364
+ set candidate to contents of (item wantIndex of tabs)
365
+ if wantTitle is "" or (name of candidate) contains wantTitle then set target to candidate
366
+ end if
367
+ if target is missing value and wantTitle is not "" then
368
+ repeat with t in tabs
369
+ if (name of t) contains wantTitle then
370
+ if target is not missing value then error "several chat tabs match " & wantTitle
371
+ set target to contents of t
372
+ end if
373
+ end repeat
374
+ end if
375
+ if target is missing value then error "chat tab " & wantIndex & " not found"
376
+ if (value of target) is not true then
377
+ perform action "AXPress" of target
378
+ delay 0.5
379
+ end if
380
+ if (value of target) is not true then error "couldn't switch to the target chat tab"
381
+ end tell
382
+ end selectChatTab
383
+
384
+ on composerControls()
385
+ set strips to my tabGroups()
386
+ if (count of strips) is 0 then error "couldn't find the composer"
387
+ tell application "System Events" to tell process "Conductor"
388
+ set pane to value of attribute "AXParent" of (item 1 of strips)
389
+ set composerBox to item 1 of (UI elements of pane whose name is "composer")
390
+ set out to {}
391
+ repeat with e in (UI elements of composerBox)
392
+ set end of out to contents of e
393
+ repeat with e2 in (UI elements of e)
394
+ set end of out to contents of e2
395
+ end repeat
396
+ end repeat
397
+ return out
398
+ end tell
399
+ end composerControls
400
+
401
+ on controlNamed(wanted)
402
+ repeat with entry in my composerControls()
403
+ set c to contents of entry
404
+ if my tabLabel(c) is wanted then return c
405
+ end repeat
406
+ return missing value
407
+ end controlNamed
408
+
409
+ on effortButton()
410
+ -- The effort control is a button whose *label is its current value*, so it is
411
+ -- identified by that label rather than a stable name.
412
+ set levels to {"Low", "Medium", "High", "Extra high", "Max", "Ultracode"}
413
+ repeat with entry in my composerControls()
414
+ set c to contents of entry
415
+ if my tabLabel(c) is in levels then return c
416
+ end repeat
417
+ return missing value
418
+ end effortButton
419
+
420
+ on setEffort(wanted)
421
+ -- Pressing cycles Low → Medium → High → Extra high → Max → Ultracode → wrap,
422
+ -- so step around the ring at most one full turn and confirm the label landed.
423
+ set btn to my effortButton()
424
+ if btn is missing value then error "couldn't find the effort control"
425
+ repeat 7 times
426
+ if my tabLabel(btn) is wanted then return
427
+ tell application "System Events" to tell process "Conductor"
428
+ perform action "AXPress" of btn
429
+ end tell
430
+ delay 0.35
431
+ set btn to my effortButton()
432
+ if btn is missing value then error "the effort control vanished mid-cycle"
433
+ end repeat
434
+ error "couldn't set effort to " & wanted
435
+ end setEffort
436
+
437
+ on setPlan(wanted)
438
+ set box to my controlNamed("Plan")
439
+ if box is missing value then error "couldn't find the Plan toggle"
440
+ tell application "System Events" to tell process "Conductor"
441
+ set current to ((value of box) as text)
442
+ if (wanted is "1" and current is "0") or (wanted is "0" and current is not "0") then
443
+ perform action "AXPress" of box
444
+ delay 0.4
445
+ if ((value of box) as text) is current then error "the Plan toggle didn't change"
446
+ end if
447
+ end tell
448
+ end setPlan
449
+
450
+ on pressFast()
451
+ -- Fast has no AX state to read (its label is always "Fast"), so the caller
452
+ -- decides whether a press is needed and re-checks the DB afterwards.
453
+ set btn to my controlNamed("Fast")
454
+ if btn is missing value then error "this model has no Fast toggle"
455
+ tell application "System Events" to tell process "Conductor"
456
+ perform action "AXPress" of btn
457
+ end tell
458
+ delay 0.4
459
+ end pressFast
460
+
461
+ on firstLine(s)
462
+ set saved to AppleScript's text item delimiters
463
+ set AppleScript's text item delimiters to linefeed
464
+ set parts to text items of s
465
+ set AppleScript's text item delimiters to saved
466
+ return item 1 of parts
467
+ end firstLine
468
+
469
+ on setModel(wanted)
470
+ set popup to missing value
471
+ repeat with entry in my composerControls()
472
+ set c to contents of entry
473
+ if my tabLabel(c) contains "Change agent" then set popup to c
474
+ end repeat
475
+ if popup is missing value then error "couldn't find the model picker"
476
+ if (my tabLabel(popup)) contains ("(" & wanted & ")") then return
477
+ tell application "System Events" to tell process "Conductor"
478
+ perform action "AXPress" of popup
479
+ end tell
480
+ delay 1.0
481
+ -- Menu labels carry badges ("Opus 5 NEW"), so an exact match is preferred but a
482
+ -- prefix match is accepted — except when it is ambiguous ("Sonnet 4.6" would
483
+ -- otherwise also match "Sonnet 4.6 1M"), which must fail rather than guess.
484
+ set chosen to missing value
485
+ set loose to {}
486
+ tell application "System Events" to tell process "Conductor"
487
+ set wa to UI element 1 of UI element 1 of UI element 1 of UI element 1 of window 1
488
+ repeat with m in (UI elements of wa whose role is "AXMenu")
489
+ repeat with mi in (UI elements of m whose role is "AXMenuItem")
490
+ set label to my firstLine(my tabLabel(mi))
491
+ if label is wanted then
492
+ set chosen to contents of mi
493
+ else if label starts with wanted then
494
+ set end of loose to contents of mi
495
+ end if
496
+ end repeat
497
+ end repeat
498
+ end tell
499
+ if chosen is missing value and (count of loose) is 1 then set chosen to item 1 of loose
500
+ if chosen is missing value then
501
+ tell application "System Events" to key code 53
502
+ if (count of loose) > 1 then error "several models match " & wanted
503
+ error "no model named " & wanted
504
+ end if
505
+ tell application "System Events" to tell process "Conductor"
506
+ perform action "AXPress" of chosen
507
+ end tell
508
+ delay 0.8
509
+ set popup to missing value
510
+ repeat with entry in my composerControls()
511
+ set c to contents of entry
512
+ if my tabLabel(c) contains "Change agent" then set popup to c
513
+ end repeat
514
+ if popup is missing value then error "the model picker vanished"
515
+ if (my tabLabel(popup)) does not contain ("(" & wanted & ")") then error "the model didn't switch to " & wanted
516
+ end setModel
517
+
518
+ on listModels()
519
+ -- Enumerate the picker rather than hard-coding a model list that would rot on
520
+ -- every Conductor release. Opens the menu, reads the labels, closes it again.
521
+ set popup to missing value
522
+ repeat with entry in my composerControls()
523
+ set c to contents of entry
524
+ if my tabLabel(c) contains "Change agent" then set popup to c
525
+ end repeat
526
+ if popup is missing value then error "couldn't find the model picker"
527
+ tell application "System Events" to tell process "Conductor"
528
+ perform action "AXPress" of popup
529
+ end tell
530
+ delay 1.0
531
+ set labels to {}
532
+ tell application "System Events" to tell process "Conductor"
533
+ set wa to UI element 1 of UI element 1 of UI element 1 of UI element 1 of window 1
534
+ repeat with m in (UI elements of wa whose role is "AXMenu")
535
+ repeat with mi in (UI elements of m whose role is "AXMenuItem")
536
+ set end of labels to my firstLine(my tabLabel(mi))
537
+ end repeat
538
+ end repeat
539
+ end tell
540
+ tell application "System Events" to key code 53
541
+ set saved to AppleScript's text item delimiters
542
+ set AppleScript's text item delimiters to linefeed
543
+ set joined to labels as text
544
+ set AppleScript's text item delimiters to saved
545
+ return joined
546
+ end listModels
547
+
548
+ on applyAgentOptions()
549
+ set wantEffort to system attribute "RELAY_SET_EFFORT"
550
+ set wantPlan to system attribute "RELAY_SET_PLAN"
551
+ set wantFast to system attribute "RELAY_SET_FAST"
552
+ set wantModel to system attribute "RELAY_SET_MODEL"
553
+ if wantModel is not "" then my setModel(wantModel)
554
+ if wantEffort is not "" then my setEffort(wantEffort)
555
+ if wantPlan is not "" then my setPlan(wantPlan)
556
+ if wantFast is "1" then my pressFast()
557
+ end applyAgentOptions`;
47
558
  /** Conductor's command palette matches workspaces by branch — its unique key. A
48
559
  * looser query (directory name) can match a command like unarchive, so prefer
49
560
  * branch and only fall back when it's absent. */
50
561
  function focusQuery(ws) {
51
562
  return ws.branch || ws.workspace_name || ws.directory_name || '';
52
563
  }
564
+ /**
565
+ * Every title Conductor might be showing for this workspace in the sidebar
566
+ * (its precedence: manual name → PR title → humanized branch → codename). The
567
+ * sidebar press tries each and requires a unique row; a miss just means we fall
568
+ * back to the palette, so this doesn't have to reproduce the precedence exactly.
569
+ */
570
+ function sidebarTitles(ws) {
571
+ const slug = ws.branch?.includes('/') ? ws.branch.slice(ws.branch.indexOf('/') + 1) : ws.branch;
572
+ const humanized = slug?.replace(/[-_]/g, ' ').trim();
573
+ return [
574
+ ws.workspace_name,
575
+ ws.pr_title,
576
+ humanized ? humanized[0].toUpperCase() + humanized.slice(1) : '',
577
+ ws.directory_name
578
+ ].filter((t) => Boolean(t));
579
+ }
580
+ /** The target rides in on the environment, like RELAY_WS_QUERY, to dodge AppleScript escaping. */
581
+ function targetEnv(target) {
582
+ return {
583
+ RELAY_TAB_INDEX: String(target.tab?.index ?? 0),
584
+ RELAY_TAB_COUNT: String(target.tab?.count ?? 0),
585
+ RELAY_TAB_TITLE: target.tab?.title ?? '',
586
+ RELAY_WS_BRANCH: target.workspace.branch ?? '',
587
+ RELAY_WS_REPO: target.workspace.repo_name ?? '',
588
+ RELAY_WS_QUERY: focusQuery(target.workspace),
589
+ RELAY_WS_TITLES: sidebarTitles(target.workspace).join('\n')
590
+ };
591
+ }
592
+ /** osascript echoes the whole failing script back; keep just the reason for the phone. */
593
+ function osaError(err) {
594
+ const raw = err instanceof Error ? err.message : String(err);
595
+ return raw.match(/execution error: (.+?) \(-?\d+\)/)?.[1] ?? raw.split('\n')[0];
596
+ }
53
597
  /**
54
598
  * Drives Conductor's real send path via macOS Accessibility (AppleScript): focus
55
599
  * the target workspace, paste the prompt, press Enter. Uses whatever model /
@@ -57,36 +601,36 @@ function focusQuery(ws) {
57
601
  * which is why it's the default.
58
602
  *
59
603
  * Precise targeting comes from focusing the intended workspace first through
60
- * Conductor's command palette (Cmd+K → branch → Enter) before pasting, so the
61
- * prompt lands in the right session regardless of what was focused — no private
62
- * IPC and nothing to rebreak on a Conductor update (unlike the sidecar).
604
+ * Conductor's command palette (Cmd+K → branch → Enter) and then selecting the
605
+ * target chat's tab (Accessibility, see SELECT_CHAT_TAB_HANDLERS), so the prompt
606
+ * lands in the right session regardless of what was focused no private IPC and
607
+ * nothing to rebreak on a Conductor update (unlike the sidecar).
63
608
  */
64
609
  export class AppleScriptActuator {
65
610
  name = 'applescript';
66
- caveat = 'Focuses the target workspace via the command palette (Cmd+K) before sending.';
611
+ caveat = 'Focuses the target workspace (Cmd+K) and its chat tab before sending.';
67
612
  precise = true;
68
613
  async send(target, text) {
69
- const navQuery = focusQuery(target.workspace);
70
- const navigate = navQuery ? FOCUS_WORKSPACE_STEPS : '';
71
- // Paste beats keystroke for long/multibyte prompts. Stash the clipboard,
72
- // focus the target workspace, paste, send, and restore.
73
- // After the palette navigates to the workspace, focus lands on a button, not
74
- // the composer — so Cmd+L (Conductor's "focus the composer" shortcut) is the
75
- // load-bearing step that puts the caret in the prompt box before we paste.
614
+ // Focus the target workspace, select its chat tab, fill the composer, send.
615
+ // Filling is an Accessibility write (no keystrokes, no clipboard); the
616
+ // clipboard paste is kept only as a fallback, and stashes/restores around it.
76
617
  const script = `
77
- set savedClipboard to the clipboard
618
+ ${SELECT_CHAT_TAB_HANDLERS}
619
+
78
620
  tell application "Conductor" to activate
79
621
  delay 0.4
80
- tell application "System Events"${navigate}
81
- set the clipboard to (do shell script "cat" & " " & quoted form of (system attribute "RELAY_PROMPT_FILE"))
82
- keystroke "l" using {command down}
83
- delay 0.3
84
- keystroke "v" using {command down}
85
- delay 0.15
622
+ my focusWorkspace()
623
+ my selectChatTab()
624
+ set promptText to my normalizeNewlines(do shell script "cat" & " " & quoted form of (system attribute "RELAY_PROMPT_FILE"))
625
+ if not (my fillComposer(promptText)) then
626
+ set savedClipboard to the clipboard
627
+ my pasteComposer()
628
+ delay 0.1
629
+ set the clipboard to savedClipboard
630
+ end if
631
+ tell application "System Events"
86
632
  key code 36
87
633
  end tell
88
- delay 0.1
89
- set the clipboard to savedClipboard
90
634
  `.trim();
91
635
  // Pass the prompt via a temp file + env to avoid AppleScript string escaping.
92
636
  const os = await import('node:os');
@@ -96,47 +640,162 @@ set the clipboard to savedClipboard
96
640
  await fs.writeFile(tmp, text, 'utf8');
97
641
  try {
98
642
  await exec('osascript', ['-e', script], {
99
- env: { ...process.env, RELAY_PROMPT_FILE: tmp, RELAY_WS_QUERY: navQuery },
100
- timeout: 15000
643
+ env: { ...process.env, RELAY_PROMPT_FILE: tmp, ...targetEnv(target) },
644
+ timeout: 20000
101
645
  });
102
646
  return { ok: true, strategy: this.name };
103
647
  }
104
648
  catch (err) {
105
- return {
106
- ok: false,
107
- strategy: this.name,
108
- error: err instanceof Error ? err.message : String(err)
109
- };
649
+ return { ok: false, strategy: this.name, error: osaError(err) };
110
650
  }
111
651
  finally {
112
652
  await fs.rm(tmp, { force: true }).catch(() => undefined);
113
653
  }
114
654
  }
115
655
  }
656
+ /**
657
+ * Conductor stores the effort level as `sessions.claude_effort_level`, but the
658
+ * composer button is labelled with the human name and *cycles* through them in
659
+ * this order. Both directions are needed: the label to press toward, and the DB
660
+ * value to confirm against.
661
+ */
662
+ export const EFFORT_LABELS = {
663
+ low: 'Low',
664
+ medium: 'Medium',
665
+ high: 'High',
666
+ xhigh: 'Extra high',
667
+ max: 'Max',
668
+ ultracode: 'Ultracode'
669
+ };
670
+ /**
671
+ * Apply agent settings to a specific chat: focus its workspace and tab (same
672
+ * verified path as a send), then drive the composer's own controls. Every step
673
+ * confirms the control landed on the requested value and errors out otherwise,
674
+ * so a half-applied change is reported rather than assumed.
675
+ */
676
+ export async function setAgentOptions(target, opts) {
677
+ if (opts.effort && !EFFORT_LABELS[opts.effort]) {
678
+ return { ok: false, strategy: 'applescript', error: `unknown effort level ${opts.effort}` };
679
+ }
680
+ const script = `
681
+ ${SELECT_CHAT_TAB_HANDLERS}
682
+
683
+ tell application "Conductor" to activate
684
+ delay 0.4
685
+ my focusWorkspace()
686
+ my selectChatTab()
687
+ my applyAgentOptions()
688
+ return "ok"`.trim();
689
+ try {
690
+ await exec('osascript', ['-e', script], {
691
+ env: {
692
+ ...process.env,
693
+ ...targetEnv(target),
694
+ RELAY_SET_EFFORT: opts.effort ? EFFORT_LABELS[opts.effort] : '',
695
+ RELAY_SET_PLAN: opts.plan === undefined ? '' : opts.plan ? '1' : '0',
696
+ RELAY_SET_FAST: opts.toggleFast ? '1' : '',
697
+ RELAY_SET_MODEL: opts.model ?? ''
698
+ },
699
+ timeout: 25000
700
+ });
701
+ return { ok: true, strategy: 'applescript' };
702
+ }
703
+ catch (err) {
704
+ return { ok: false, strategy: 'applescript', error: osaError(err) };
705
+ }
706
+ }
707
+ /** The model labels Conductor is currently offering, read off the live picker. */
708
+ export async function listAgentModels(target) {
709
+ const script = `
710
+ ${SELECT_CHAT_TAB_HANDLERS}
711
+
712
+ tell application "Conductor" to activate
713
+ delay 0.4
714
+ my focusWorkspace()
715
+ my selectChatTab()
716
+ return my listModels()`.trim();
717
+ try {
718
+ const { stdout } = await exec('osascript', ['-e', script], {
719
+ env: { ...process.env, ...targetEnv(target) },
720
+ timeout: 25000
721
+ });
722
+ const models = stdout
723
+ .split('\n')
724
+ .map(s => s.trim())
725
+ .filter(Boolean);
726
+ return { ok: true, models };
727
+ }
728
+ catch (err) {
729
+ return { ok: false, error: osaError(err) };
730
+ }
731
+ }
732
+ /**
733
+ * Create a *new workspace*, optionally with a first prompt, via Conductor's
734
+ * deep-link scheme (conductor.build/docs/reference/deep-links).
735
+ *
736
+ * This is the one write here that touches no UI at all: no Accessibility, no
737
+ * keystrokes, no focus dependency — macOS hands the URL to Conductor and it
738
+ * creates the worktree. Nothing to rebreak on an update, unlike every other
739
+ * path in this file.
740
+ *
741
+ * Three things the scheme dictates:
742
+ * - Parameters sit *flat* after the scheme (`conductor://prompt=…&path=…`), not
743
+ * behind a `?`, and every value must be URL-encoded — which is also what stops
744
+ * a prompt containing `&path=` from redirecting the workspace to another repo.
745
+ * - **An unmatched (or absent) `path` silently falls back to the first repo**, so
746
+ * the caller resolves a real `root_path` first rather than trusting a name.
747
+ * - **`prompt` is optional**: a bare `conductor://path=…` opens an empty
748
+ * workspace, same as Conductor's own New workspace. That form isn't in the
749
+ * docs (every documented route carries a prompt) but is verified against the
750
+ * live app — so if it ever stops working, this is the line to suspect.
751
+ *
752
+ * The link is fire-and-forget: it reports that Conductor was *handed* the URL,
753
+ * never that a workspace appeared. The caller watches the DB for that.
754
+ */
755
+ export async function createWorkspace(prompt, repoPath) {
756
+ if (!prompt.trim() && !repoPath) {
757
+ return { ok: false, strategy: 'deeplink', error: 'a new workspace needs a repo or a first prompt' };
758
+ }
759
+ const query = [
760
+ prompt.trim() ? `prompt=${encodeURIComponent(prompt)}` : '',
761
+ repoPath ? `path=${encodeURIComponent(repoPath)}` : ''
762
+ ]
763
+ .filter(Boolean)
764
+ .join('&');
765
+ try {
766
+ await exec('open', [`conductor://${query}`], { timeout: 15000 });
767
+ return { ok: true, strategy: 'deeplink' };
768
+ }
769
+ catch (err) {
770
+ return { ok: false, strategy: 'deeplink', error: osaError(err) };
771
+ }
772
+ }
116
773
  /**
117
774
  * Open a new chat in the target workspace — Conductor's "New chat, same files"
118
775
  * (Cmd+T). Focuses the workspace first (command palette → branch), then Cmd+T; the
119
776
  * caller detects the freshly-created session id from the DB.
120
777
  */
121
778
  export async function newChat(workspace) {
122
- const navQuery = focusQuery(workspace);
123
- if (!navQuery)
779
+ if (!focusQuery(workspace))
124
780
  return { ok: false, strategy: 'applescript', error: 'workspace has no branch to focus' };
125
781
  const script = `
782
+ ${SELECT_CHAT_TAB_HANDLERS}
783
+
126
784
  tell application "Conductor" to activate
127
785
  delay 0.4
128
- tell application "System Events"${FOCUS_WORKSPACE_STEPS}
786
+ my focusWorkspace()
787
+ tell application "System Events"
129
788
  keystroke "t" using {command down}
130
789
  end tell`.trim();
131
790
  try {
132
791
  await exec('osascript', ['-e', script], {
133
- env: { ...process.env, RELAY_WS_QUERY: navQuery },
792
+ env: { ...process.env, ...targetEnv({ workspace, sessionId: null }) },
134
793
  timeout: 15000
135
794
  });
136
795
  return { ok: true, strategy: 'applescript' };
137
796
  }
138
797
  catch (err) {
139
- return { ok: false, strategy: 'applescript', error: err instanceof Error ? err.message : String(err) };
798
+ return { ok: false, strategy: 'applescript', error: osaError(err) };
140
799
  }
141
800
  }
142
801
  export function pickActuator(strategy) {