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