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