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.
- package/dist/assets/index-0lGqzMHX.css +1 -0
- package/dist/assets/index-q0jubVD3.js +40 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/src/reads.js +19 -1
- package/dist-node/src/server.js +166 -2
- package/dist-node/src/writes.js +702 -43
- package/package.json +1 -1
- package/dist/assets/index-D6B0k-tm.css +0 -1
- package/dist/assets/index-qjWM9xpd.js +0 -40
package/dist-node/src/writes.js
CHANGED
|
@@ -32,24 +32,568 @@ export class SidecarActuator {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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)
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
//
|
|
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
|
-
|
|
618
|
+
${SELECT_CHAT_TAB_HANDLERS}
|
|
619
|
+
|
|
78
620
|
tell application "Conductor" to activate
|
|
79
621
|
delay 0.4
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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,
|
|
100
|
-
timeout:
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
798
|
+
return { ok: false, strategy: 'applescript', error: osaError(err) };
|
|
140
799
|
}
|
|
141
800
|
}
|
|
142
801
|
export function pickActuator(strategy) {
|