conductor-remote 1.117.0 → 1.118.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/README.md +10 -8
  2. package/dist/assets/{PierrePatch-D3_YBIAr.js → PierrePatch-D4YbKD8Q.js} +1 -1
  3. package/dist/assets/index-BRzsSW8g.css +1 -0
  4. package/dist/assets/index-BaS6qXUW.js +65 -0
  5. package/dist/index.html +2 -2
  6. package/dist/push-sw.js +2 -2
  7. package/dist/sw.js +1 -1
  8. package/dist-node/scripts/nosleep-setup.js +2 -2
  9. package/dist-node/scripts/nosleep.js +3 -3
  10. package/dist-node/scripts/service/commands.js +32 -0
  11. package/dist-node/scripts/service/configuration.js +205 -0
  12. package/dist-node/scripts/service/environment.js +59 -0
  13. package/dist-node/scripts/service/flags.js +45 -0
  14. package/dist-node/scripts/service/installation.js +91 -0
  15. package/dist-node/scripts/service/launch-agent.js +134 -0
  16. package/dist-node/scripts/service/network.js +244 -0
  17. package/dist-node/scripts/service/presentation.js +101 -0
  18. package/dist-node/scripts/service/voice.js +113 -0
  19. package/dist-node/scripts/service.js +5 -980
  20. package/dist-node/src/{agent-config.js → agents/agent-config.js} +1 -1
  21. package/dist-node/src/{model-cache.js → agents/model-cache.js} +1 -1
  22. package/dist-node/src/{roles.js → agents/roles.js} +3 -3
  23. package/dist-node/src/db.js +1 -1
  24. package/dist-node/src/{parked.js → delivery/parked.js} +1 -1
  25. package/dist-node/src/{dev-server.js → dev-server/controller.js} +8 -291
  26. package/dist-node/src/dev-server/ports.js +63 -0
  27. package/dist-node/src/dev-server/processes.js +73 -0
  28. package/dist-node/src/dev-server/proxy.js +158 -0
  29. package/dist-node/src/{run-activity.js → dev-server/run-activity.js} +2 -2
  30. package/dist-node/src/dev-server/types.js +1 -0
  31. package/dist-node/src/{file-preview.js → files/file-preview.js} +1 -1
  32. package/dist-node/src/{change-stats.js → git/change-stats.js} +1 -1
  33. package/dist-node/src/{git.js → git/diff.js} +1 -1
  34. package/dist-node/src/{autoupdate.js → host/autoupdate.js} +1 -1
  35. package/dist-node/src/{funnel-watchdog.js → host/funnel-watchdog.js} +4 -4
  36. package/dist-node/src/{logbuf.js → host/logbuf.js} +1 -1
  37. package/dist-node/src/{nosleep-helper.js → host/nosleep-helper.js} +1 -1
  38. package/dist-node/src/{tailscale.js → host/tailscale.js} +1 -1
  39. package/dist-node/src/http/router-types.js +2 -0
  40. package/dist-node/src/http/router.js +78 -0
  41. package/dist-node/src/http/routes/create-workspace.js +90 -0
  42. package/dist-node/src/http/routes/files.js +53 -0
  43. package/dist-node/src/http/routes/prompts.js +363 -0
  44. package/dist-node/src/http/routes/sessions.js +279 -0
  45. package/dist-node/src/http/routes/state.js +186 -0
  46. package/dist-node/src/http/routes/system.js +220 -0
  47. package/dist-node/src/http/routes/voice.js +117 -0
  48. package/dist-node/src/http/routes/workflows.js +248 -0
  49. package/dist-node/src/http/routes/workspaces.js +267 -0
  50. package/dist-node/src/http/services/base.js +252 -0
  51. package/dist-node/src/http/services/delegations.js +301 -0
  52. package/dist-node/src/http/services/delivery.js +433 -0
  53. package/dist-node/src/http/services/files.js +199 -0
  54. package/dist-node/src/http/services/mcp.js +94 -0
  55. package/dist-node/src/http/services/responses.js +120 -0
  56. package/dist-node/src/http/services/voice.js +147 -0
  57. package/dist-node/src/http/services/workflow-probes.js +215 -0
  58. package/dist-node/src/http/services/workflow-state.js +195 -0
  59. package/dist-node/src/http/services/workflow.js +366 -0
  60. package/dist-node/src/http/services.js +35 -0
  61. package/dist-node/src/mcp/arguments.js +14 -0
  62. package/dist-node/src/mcp/dispatcher.js +56 -0
  63. package/dist-node/src/mcp/formatters.js +110 -0
  64. package/dist-node/src/mcp/protocol.js +7 -0
  65. package/dist-node/src/mcp/registry.js +66 -0
  66. package/dist-node/src/mcp/tools/agent-options.js +122 -0
  67. package/dist-node/src/mcp/tools/chat-actions.js +194 -0
  68. package/dist-node/src/mcp/tools/chats.js +194 -0
  69. package/dist-node/src/mcp/tools/delegation.js +135 -0
  70. package/dist-node/src/mcp/tools/system.js +135 -0
  71. package/dist-node/src/mcp/tools/voice.js +136 -0
  72. package/dist-node/src/mcp/tools/workspaces.js +259 -0
  73. package/dist-node/src/mcp/types.js +1 -0
  74. package/dist-node/src/mcp.js +6 -4
  75. package/dist-node/src/{notify.js → notifications/notify.js} +5 -5
  76. package/dist-node/src/{webpush.js → notifications/webpush.js} +1 -1
  77. package/dist-node/src/orchestration/delegation/codec.js +201 -0
  78. package/dist-node/src/{delegation-intake.js → orchestration/delegation/intake.js} +2 -2
  79. package/dist-node/src/{delegation-prompt.js → orchestration/delegation/prompt.js} +1 -1
  80. package/dist-node/src/orchestration/delegation/queue.js +255 -0
  81. package/dist-node/src/orchestration/delegation/store.js +111 -0
  82. package/dist-node/src/orchestration/delegation/types.js +2 -0
  83. package/dist-node/src/orchestration/persistence/capabilities.js +109 -0
  84. package/dist-node/src/orchestration/persistence/codecs.js +148 -0
  85. package/dist-node/src/orchestration/persistence/connection.js +205 -0
  86. package/dist-node/src/orchestration/persistence/db.js +101 -0
  87. package/dist-node/src/orchestration/persistence/effect-recovery.js +227 -0
  88. package/dist-node/src/orchestration/persistence/effect-transitions.js +157 -0
  89. package/dist-node/src/orchestration/persistence/effects.js +109 -0
  90. package/dist-node/src/orchestration/persistence/errors.js +39 -0
  91. package/dist-node/src/orchestration/persistence/events.js +29 -0
  92. package/dist-node/src/orchestration/persistence/idempotency.js +82 -0
  93. package/dist-node/src/orchestration/persistence/job-attempts.js +114 -0
  94. package/dist-node/src/orchestration/persistence/jobs.js +159 -0
  95. package/dist-node/src/orchestration/persistence/projections.js +190 -0
  96. package/dist-node/src/orchestration/persistence/quarantine.js +46 -0
  97. package/dist-node/src/orchestration/persistence/records.js +115 -0
  98. package/dist-node/src/orchestration/persistence/relays.js +78 -0
  99. package/dist-node/src/orchestration/persistence/runs.js +156 -0
  100. package/dist-node/src/{orchestration-schema.js → orchestration/persistence/schema.js} +1 -1
  101. package/dist-node/src/orchestration/persistence/types.js +1 -0
  102. package/dist-node/src/orchestration/persistence/ui-lease.js +197 -0
  103. package/dist-node/src/orchestration/persistence/values.js +114 -0
  104. package/dist-node/src/orchestration/workflow/commands.js +309 -0
  105. package/dist-node/src/orchestration/workflow/coordinator.js +28 -0
  106. package/dist-node/src/orchestration/workflow/effect-recovery.js +125 -0
  107. package/dist-node/src/orchestration/workflow/effects.js +307 -0
  108. package/dist-node/src/orchestration/workflow/errors.js +13 -0
  109. package/dist-node/src/orchestration/workflow/helpers.js +154 -0
  110. package/dist-node/src/orchestration/workflow/job-dispatch.js +312 -0
  111. package/dist-node/src/orchestration/workflow/job-results.js +280 -0
  112. package/dist-node/src/{workflow.js → orchestration/workflow/prompts.js} +2 -2
  113. package/dist-node/src/orchestration/workflow/root.js +293 -0
  114. package/dist-node/src/orchestration/workflow/start.js +99 -0
  115. package/dist-node/src/orchestration/workflow/state.js +105 -0
  116. package/dist-node/src/orchestration/workflow/types.js +1 -0
  117. package/dist-node/src/orchestration/workflow/wake.js +164 -0
  118. package/dist-node/src/prefs.js +1 -1
  119. package/dist-node/src/{background-tasks.js → reads/background-tasks.js} +1 -1
  120. package/dist-node/src/reads/messages.js +362 -0
  121. package/dist-node/src/reads/repository.js +87 -0
  122. package/dist-node/src/reads/sessions.js +165 -0
  123. package/dist-node/src/reads/types.js +1 -0
  124. package/dist-node/src/reads/workspaces.js +230 -0
  125. package/dist-node/src/reads/worktrees.js +45 -0
  126. package/dist-node/src/routes.js +5 -5
  127. package/dist-node/src/{search.js → search/coordinator.js} +4 -4
  128. package/dist-node/src/{search-worker.js → search/worker.js} +3 -3
  129. package/dist-node/src/server.js +14 -3930
  130. package/dist-node/src/settings.js +1 -1
  131. package/dist-node/src/shared.js +10 -10
  132. package/dist-node/src/{transcript.js → transcript/parser.js} +4 -4
  133. package/dist-node/src/{tool-usage-worker.js → usage/tool-usage-worker.js} +1 -1
  134. package/dist-node/src/{tool-usage.js → usage/tool-usage.js} +1 -1
  135. package/dist-node/src/voice/brief.js +22 -50
  136. package/dist-node/src/voice/context.js +1 -1
  137. package/dist-node/src/voice/dates.js +38 -0
  138. package/dist-node/src/voice/history.js +14 -5
  139. package/dist-node/src/voice/prompt.js +10 -9
  140. package/dist-node/src/voice/recall.js +115 -0
  141. package/dist-node/src/{speech.js → voice/speech.js} +1 -1
  142. package/dist-node/src/voice/tools.js +92 -3
  143. package/dist-node/src/voice/webrtc.js +1 -1
  144. package/dist-node/src/wire.js +2 -2
  145. package/dist-node/src/writes/actuator.js +124 -0
  146. package/dist-node/src/writes/agent-options.js +151 -0
  147. package/dist-node/src/writes/applescript/agent-options.applescript +334 -0
  148. package/dist-node/src/writes/applescript/chat-targeting.applescript +149 -0
  149. package/dist-node/src/writes/applescript/composer.applescript +195 -0
  150. package/dist-node/src/writes/applescript/hotspot.applescript +155 -0
  151. package/dist-node/src/writes/applescript/lifecycle.applescript +266 -0
  152. package/dist-node/src/writes/applescript/run-tasks.applescript +208 -0
  153. package/dist-node/src/writes/applescript/source.js +52 -0
  154. package/dist-node/src/writes/applescript/window.applescript +436 -0
  155. package/dist-node/src/writes/applescript/workspace-status.applescript +177 -0
  156. package/dist-node/src/writes/applescript/workspace-targeting.applescript +359 -0
  157. package/dist-node/src/writes/chats.js +160 -0
  158. package/dist-node/src/writes/guards.js +77 -0
  159. package/dist-node/src/writes/runner.js +67 -0
  160. package/dist-node/src/{sidecar.js → writes/sidecar.js} +1 -1
  161. package/dist-node/src/writes/system.js +81 -0
  162. package/dist-node/src/writes/targeting.js +121 -0
  163. package/dist-node/src/writes/types.js +1 -0
  164. package/dist-node/src/writes/ui-lock.js +229 -0
  165. package/dist-node/src/writes/workspaces.js +233 -0
  166. package/docs/voice-setup.md +34 -12
  167. package/package.json +2 -2
  168. package/dist/assets/index-ByTwVInL.css +0 -1
  169. package/dist/assets/index-LAm9X7wv.js +0 -65
  170. package/dist-node/src/conductor.applescript +0 -2279
  171. package/dist-node/src/delegations.js +0 -557
  172. package/dist-node/src/mcp-tools.js +0 -1319
  173. package/dist-node/src/orchestration-db.js +0 -2186
  174. package/dist-node/src/reads.js +0 -787
  175. package/dist-node/src/workflow-coordinator.js +0 -2119
  176. package/dist-node/src/writes.js +0 -1223
  177. /package/dist-node/src/{conductor-settings.js → agents/conductor-settings.js} +0 -0
  178. /package/dist-node/src/{firstprompt.js → delivery/firstprompt.js} +0 -0
  179. /package/dist-node/src/{sendonce.js → delivery/sendonce.js} +0 -0
  180. /package/dist-node/src/{preview-urls.js → dev-server/preview-urls.js} +0 -0
  181. /package/dist-node/src/{run-configs.js → dev-server/run-configs.js} +0 -0
  182. /package/dist-node/src/{attachments.js → files/attachments.js} +0 -0
  183. /package/dist-node/src/{icons.js → files/icons.js} +0 -0
  184. /package/dist-node/src/{staged-attachments.js → files/staged-attachments.js} +0 -0
  185. /package/dist-node/src/{fork-workspace.js → git/fork-workspace.js} +0 -0
  186. /package/dist-node/src/{merge.js → git/merge.js} +0 -0
  187. /package/dist-node/src/{pr.js → git/pr.js} +0 -0
  188. /package/dist-node/src/{nosleep.js → host/nosleep.js} +0 -0
  189. /package/dist-node/src/{relay-processes.js → host/relay-processes.js} +0 -0
  190. /package/dist-node/src/{ui-lease-watchdog.js → host/ui-lease-watchdog.js} +0 -0
  191. /package/dist-node/src/{wifi.js → host/wifi.js} +0 -0
  192. /package/dist-node/src/{orchestration-schema.generated.js → orchestration/persistence/schema.generated.js} +0 -0
  193. /package/dist-node/src/{workflow-effect-runner.js → orchestration/workflow/effect-runner.js} +0 -0
  194. /package/dist-node/src/{workflow-http.js → orchestration/workflow/http.js} +0 -0
  195. /package/dist-node/src/{workflow-machine.js → orchestration/workflow/machine.js} +0 -0
  196. /package/dist-node/src/{session-poller.js → reads/session-poller.js} +0 -0
  197. /package/dist-node/src/{context-breakdown.js → transcript/context-breakdown.js} +0 -0
  198. /package/dist-node/src/{chat-cursor.js → transcript/cursor.js} +0 -0
  199. /package/dist-node/src/{plan-usage.js → usage/plan-usage.js} +0 -0
  200. /package/dist-node/src/{tool-usage-service.js → usage/tool-usage-service.js} +0 -0
@@ -1,2279 +0,0 @@
1
- (*
2
- Everything the relay drives Conductor's UI with, in one place.
3
-
4
- Loaded verbatim by src/writes.ts, which appends a few lines naming the
5
- handlers to run and hands the result to `osascript`. It lives in its own file
6
- rather than a TypeScript template literal for three reasons: an editor can
7
- highlight it, `yarn verify` can compile-check it (scripts/check-applescript.ts,
8
- the repository validator for this language), and a backtick in a comment
9
- no longer terminates the string it used to sit inside — so this sentence,
10
- which contains `one`, is now harmless.
11
-
12
- The target rides in on the environment (RELAY_WS_*, RELAY_TAB_*, RELAY_SET_*,
13
- RELAY_PROMPT_FILE) rather than being interpolated, which keeps AppleScript
14
- string escaping out of the picture entirely. The three matching labels use a
15
- UTF-8 temporary file because `system attribute` decodes text as MacRoman.
16
-
17
- Two traps to respect when editing:
18
-
19
- - Inside a `tell application "System Events"` block, ordinary-looking variable
20
- names resolve as System Events terms. `tabs` and `groups` become "every
21
- tab/group of the process", so a loop over your own list quietly iterates the
22
- wrong thing and the handler returns nothing. Keep logic outside the tell and
23
- reach in through one-line helpers, or name the variables `strip` and `pane`.
24
- - Every read is rooted at `window 1`, and Conductor keeps running with its
25
- windows closed. Go through `requireWindow` / `webArea` so a window that
26
- closed mid-run says so in words.
27
-
28
- How the chat targeting works, and why each step is here:
29
-
30
- `openViaDeepLink` normally selects the chat already, by id. The handlers below
31
- are what confirm that, and what still does the work for the fallback focus
32
- paths, which only reach the right workspace. A workspace holds several chats
33
- and Conductor keeps typing in whichever tab is already active, so a send
34
- addressed to a non-active chat would land in the wrong agent.
35
-
36
- Conductor's webview exposes its whole tree through macOS Accessibility. The
37
- chat strip is an AXTabGroup whose AXRadioButtons are the tabs (AXValue marks
38
- the selected one, AXPress switches to it and does *not* close the chat). The
39
- strip's order matches reads.listSessions (created_at ASC), so the caller
40
- addresses a tab by 1-based index and we cross-check the label.
41
-
42
- Three traps that targeting has to survive:
43
-
44
- - The tabs are not the strip's direct children. Each sits one AXGroup deep and
45
- is named for its own close action ("Close chat <title>"), while a "Close file
46
- view" radio button, which is not a chat, *is* a direct child. Reading the
47
- direct children and stopping there made every chat strip look like a one-tab
48
- strip called "Close file view", which then lost the scoring below to the
49
- terminal panel and failed every send with "chat tab 1 not found". So
50
- `stripRadios` reads both levels and `chatTabs` keeps the "Close chat" ones.
51
- - The terminal panel is an AXTabGroup too (radio buttons named Setup / Run /
52
- Terminal 1), a sibling of the chat strip in the same pane. Picking "the first
53
- tab group" would sometimes press a terminal tab, so candidates are *scored*
54
- on tab count plus label and a tie refuses to act.
55
- - The palette can land on the wrong workspace (a loose query matches a command;
56
- a deleted branch opens a modal). The pane header carries the branch and repo,
57
- so we read them back and bail if they disagree.
58
-
59
- Target is read from RELAY_TAB_{INDEX,COUNT,TITLE} and RELAY_WS_{BRANCH,REPO};
60
- index 0 disables the tab step. Every failure path errors out so the caller
61
- aborts *before* typing — landing in the wrong chat is worse than not sending.
62
- *)
63
-
64
- on splitLines(s)
65
- -- `do shell script` converts the file's LF separators to AppleScript returns.
66
- -- `paragraphs` handles that form and keeps empty fields between separators.
67
- return paragraphs of s
68
- end splitLines
69
-
70
- -- The target file's first two lines are tab title and palette query. The
71
- -- remaining lines are sidebar-title candidates. The path is ASCII; the file
72
- -- contents reach AppleScript through `do shell script` as UTF-8.
73
- on targetField(fieldIndex)
74
- set fields to my splitLines(do shell script "cat " & quoted form of (system attribute "RELAY_TARGET_FILE"))
75
- if fieldIndex > (count of fields) then return ""
76
- return item fieldIndex of fields
77
- end targetField
78
-
79
- on targetSidebarTitles()
80
- set fields to my splitLines(do shell script "cat " & quoted form of (system attribute "RELAY_TARGET_FILE"))
81
- if (count of fields) < 3 then return {}
82
- set titles to {}
83
- repeat with fieldIndex from 3 to (count of fields)
84
- set end of titles to item fieldIndex of fields
85
- end repeat
86
- return titles
87
- end targetSidebarTitles
88
-
89
- on windowProbe()
90
- -- One read, three outcomes, none of them guessed: {count, errNum, errText}.
91
- -- count is -1 when macOS refused the read at all. Everything that wants to
92
- -- know about windows goes through here, because "returned 0" and "refused to
93
- -- answer" are different facts and only this handler still has both.
94
- -- The timeout is load-bearing: a wedged Conductor (seen live after a restart
95
- -- collided with a relaunch) answers deep links but hangs every AX read, and
96
- -- without a cap this call blocks until the node-side ceiling kills the whole
97
- -- run as "took too long" — 28s of silence instead of a named cause. 6s turns
98
- -- that into error -1712, which refusalReason maps to words.
99
- try
100
- with timeout of 6 seconds
101
- tell application "System Events" to tell process "Conductor"
102
- set winCount to (count of windows)
103
- end tell
104
- end timeout
105
- return {winCount, 0, ""}
106
- on error errText number errNum
107
- return {-1, errNum, errText}
108
- end try
109
- end windowProbe
110
-
111
- on hasWindow()
112
- -- Also false when the process is gone or macOS refused: callers that only
113
- -- need a boolean keep getting one. windowFailure() is what tells them why.
114
- return (item 1 of my windowProbe()) > 0
115
- end hasWindow
116
-
117
- on refusalReason(probe)
118
- -- macOS's own words, matched on the error it actually returned rather than on
119
- -- a capability flag we hope is accurate. "UI elements enabled" was the earlier
120
- -- attempt at this and is not trustworthy — it can report true for a process
121
- -- that is not itself trusted, and then the refusal reappears downstream as
122
- -- "no open window". These strings are the two grants the write path needs.
123
- set errNum to item 2 of probe
124
- set errText to item 3 of probe
125
- 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
126
- 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
127
- if errNum is -1712 then return "Conductor stopped answering UI reads (the Accessibility query timed out) - the app looks wedged even though it may still process deep links. Force-quit Conductor on your Mac and open it again; if it repeats with a healthy Conductor, System Events is stuck - run: killall 'System Events'"
128
- return ""
129
- end refusalReason
130
-
131
- on conductorRunning()
132
- try
133
- tell application "System Events" to return (exists process "Conductor")
134
- on error
135
- return false
136
- end try
137
- end conductorRunning
138
-
139
- on joinList(lst, sep)
140
- set saved to AppleScript's text item delimiters
141
- set AppleScript's text item delimiters to sep
142
- set out to lst as text
143
- set AppleScript's text item delimiters to saved
144
- return out
145
- end joinList
146
-
147
- on processWindowCounts()
148
- -- Every process whose name looks like Conductor, with its window count. If the
149
- -- app is visibly on screen while "process Conductor" reports zero, the windows
150
- -- belong to a process we are not addressing — and that only shows up here.
151
- set out to {}
152
- try
153
- tell application "System Events"
154
- repeat with proc in (every process whose name contains "Conductor")
155
- set end of out to ((name of proc) & "=" & (count of windows of proc))
156
- end repeat
157
- end tell
158
- end try
159
- return out
160
- end processWindowCounts
161
-
162
- on menuTitles()
163
- try
164
- tell application "System Events" to tell process "Conductor"
165
- return name of every menu bar item of menu bar 1
166
- end tell
167
- on error
168
- return {}
169
- end try
170
- end menuTitles
171
-
172
- on serverWindowCount()
173
- -- Second opinion from the window server, because the Accessibility tree only
174
- -- shows windows on the *current* Space: a full-screen Conductor (which owns its
175
- -- own Space) or a Mac sitting at the lock screen reads as "no window" from AX
176
- -- while the window very much exists — and acting on the AX answer alone once
177
- -- quit a Conductor whose window the user had left open all along.
178
- -- CGWindowListCopyWindowInfo(kCGWindowListOptionAll) sees every Space; owner
179
- -- name and layer are readable without the Screen Recording grant (only window
180
- -- *titles* are gated). Layer 0 keeps it to real windows. -1 = the probe itself
181
- -- failed, which callers must treat as "no opinion", never as "no window".
182
- -- The bindFunction is the load-bearing part, twice over: as shipped this read
183
- -- "$.CFBridgingRelease(...)", which is an *inline*, not a symbol, and calling it
184
- -- through JXA segfaults the osascript child (measured: exit 139) — so this probe
185
- -- returned -1 on every call and the veto it feeds never once fired. And without
186
- -- the rebind, JXA types the CF return as a bare pointer that deepUnwrap answers
187
- -- with undefined — same wrong verdict, silently. Rebinding the function to
188
- -- return 'id' makes the array real (verified live: 12 windows, behind a lock).
189
- set jxa to "ObjC.import('CoreGraphics'); ObjC.bindFunction('CGWindowListCopyWindowInfo', ['id', ['unsigned int', 'unsigned int']]); const w = ObjC.deepUnwrap($.CGWindowListCopyWindowInfo(0, 0)) || []; w.filter(x => x.kCGWindowOwnerName === 'Conductor' && x.kCGWindowLayer === 0).length"
190
- try
191
- return (do shell script "osascript -l JavaScript -e " & quoted form of jxa) as integer
192
- on error
193
- return -1
194
- end try
195
- end serverWindowCount
196
-
197
- on screenLocked()
198
- -- "locked" / "unlocked" / "unknown". CGSessionCopyCurrentDictionary carries
199
- -- CGSSessionScreenIsLocked = 1 while the lock screen is up and omits the key
200
- -- entirely when it is not — same stdlib JXA channel as serverWindowCount.
201
- -- This matters because the lock screen is the product's home ground: sends
202
- -- come from a phone precisely when nobody is at the Mac, so "is the lock up"
203
- -- is the difference between a state a restart can fix and one it provably
204
- -- makes worse (see the restart gate in activateConductor).
205
- -- Same two traps as serverWindowCount: $.CFBridgingRelease segfaults under JXA,
206
- -- and without the rebind deepUnwrap reads the CF dictionary as undefined — which
207
- -- the "|| {}" then turned into a confident "unlocked" on a locked Mac. The fixed
208
- -- probe was verified against a genuinely locked screen (CGSSessionScreenIsLocked
209
- -- true, every session key present).
210
- set jxa to "ObjC.import('CoreGraphics'); ObjC.bindFunction('CGSessionCopyCurrentDictionary', ['id', []]); const d = ObjC.deepUnwrap($.CGSessionCopyCurrentDictionary()) || {}; d.CGSSessionScreenIsLocked ? 'locked' : 'unlocked'"
211
- try
212
- return do shell script "osascript -l JavaScript -e " & quoted form of jxa
213
- on error
214
- return "unknown"
215
- end try
216
- end screenLocked
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, what its menus offer, how
223
- -- many windows the window server counts across all Spaces (AX only sees the
224
- -- current one), and whether the lock screen is up.
225
- set procs to my processWindowCounts()
226
- set titles to my menuTitles()
227
- set ev to " [window server: " & my serverWindowCount() & "; screen: " & my screenLocked() & "]"
228
- if (count of procs) > 0 then set ev to ev & " [processes: " & my joinList(procs, ", ") & "]"
229
- if (count of titles) > 0 then set ev to ev & " [menus: " & my joinList(titles, ", ") & "]"
230
- return ev
231
- end windowEvidence
232
-
233
- on windowFailure()
234
- -- "" when a window is there; otherwise why not, in words the phone can act on.
235
- -- ASCII on purpose: these reach the phone, and osascript decodes -e by the
236
- -- caller's locale, which a LaunchAgent doesn't set. The last branch carries
237
- -- the raw error number and text on purpose — an unrecognised refusal must
238
- -- arrive as itself, not as the most plausible-sounding of the known causes.
239
- set probe to my windowProbe()
240
- set winCount to item 1 of probe
241
- if winCount > 0 then return ""
242
- 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()
243
- set refusal to my refusalReason(probe)
244
- if refusal is not "" then return refusal
245
- 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."
246
- return "couldn't read Conductor's windows (" & (item 2 of probe) & "): " & (item 3 of probe)
247
- end windowFailure
248
-
249
- on requireWindow()
250
- set reason to my windowFailure()
251
- if reason is not "" then error reason
252
- end requireWindow
253
-
254
- on dockClick()
255
- -- Last resort when the "reopen" AppleEvent draws nothing: click the app's Dock
256
- -- tile. Same intent, different delivery — the Dock raises it through the app's
257
- -- own event loop rather than as an Apple event we send, which is what an app
258
- -- that never wired up reopen can still answer. (There is no "New Window" menu
259
- -- item to press: Conductor is single-window and single-instance.)
260
- try
261
- tell application "System Events" to tell application process "Dock"
262
- click UI element "Conductor" of list 1
263
- end tell
264
- return true
265
- on error
266
- return false
267
- end try
268
- end dockClick
269
-
270
- on restartConductor()
271
- -- The last lever: Conductor is single-window and single-instance, so when a
272
- -- running app answers neither reopen nor a Dock click there is nothing left to
273
- -- press. Deliberately fire-and-forget — a cold launch is 5-10s on its own,
274
- -- past what the phone's budget leaves once the send's own delays are counted —
275
- -- so this returns as soon as the relaunch is under way and the caller tells the
276
- -- phone to send again into a warm app. Gated by RELAY_ALLOW_RESTART, which the
277
- -- relay only sets when the DB says no session is mid-turn.
278
- try
279
- tell application "Conductor" to quit
280
- on error errText
281
- return "couldn't quit Conductor to restart it: " & errText
282
- end try
283
- repeat with attempt from 1 to 16
284
- if not (my conductorRunning()) then exit repeat
285
- delay 0.25
286
- end repeat
287
- if my conductorRunning() then return "Conductor ignored the quit, so the relay left it alone - quit it on your Mac and try again."
288
- try
289
- tell application "Conductor" to activate
290
- on error errText
291
- return "quit Conductor but couldn't start it again: " & errText
292
- end try
293
- return ""
294
- end restartConductor
295
-
296
- on restartApp()
297
- -- The restart the phone asks for (POST /api/conductor/restart), as opposed to
298
- -- the one activateConductor falls back to on its own. Same lever, different
299
- -- entry, and the second entry is the whole point: that fallback fires only when
300
- -- a *running* Conductor draws no window, so it is unreachable for the failure
301
- -- this exists for — a Conductor whose window, sidebar and composer are all fine
302
- -- while its agent runtime has stopped producing output. Measured live
303
- -- (2026-09-02): prompts kept landing as `session_messages` rows for hours after
304
- -- the last agent frame, so every window probe here would have said "healthy".
305
- --
306
- -- RELAY_ALLOW_RESTART is deliberately NOT consulted. That flag exists so the
307
- -- fallback never destroys work the caller didn't ask about; here the caller is
308
- -- a person who pressed a button, and server.ts has already counted the working
309
- -- chats from the DB and refused without `stopAgents`.
310
- set refusal to my refusalReason(my windowProbe())
311
- if refusal is not "" then error refusal
312
- -- Behind the lock screen a relaunch comes up windowless, and once came up
313
- -- wedged (answering deep links, hanging every AX read) — the same reason the
314
- -- fallback refuses there. The sentence starts with the phrase both sides match
315
- -- on (MAC_LOCKED in src/shared.ts) so the phone draws its unlock link.
316
- if my screenLocked() is "locked" then error "The Mac is locked - relaunching Conductor behind the lock screen is what leaves it wedged, so the relay won't. Unlock the Mac and try again." & my windowEvidence()
317
- set restartError to my restartConductor()
318
- if restartError is not "" then error restartError & my windowEvidence()
319
- -- The fallback returns the moment the relaunch starts, because it is spending a
320
- -- *send's* budget and a cold launch doesn't fit beside one. This run has nothing
321
- -- else to do with its budget, so it waits for the window and the phone hears
322
- -- what actually happened instead of "try again": ~5-10s for a cold launch here.
323
- if not my waitForWindow(60) then error "Conductor was relaunched but hasn't drawn a window yet. Give it a few seconds, then try again." & my windowEvidence()
324
- return "ok"
325
- end restartApp
326
-
327
- on waitForWindow(attempts)
328
- -- "reopen" is the dock-click Apple event, and for most apps that is what
329
- -- recreates a closed window ("activate" on its own does not). Nudge a few times
330
- -- across the wait: a cold launch can reach "process exists" long before it
331
- -- draws anything. If reopen has drawn nothing by ~3s the app isn't answering
332
- -- that event, so escalate to a real Dock click rather than repeating what has
333
- -- already failed twice.
334
- repeat with attempt from 1 to attempts
335
- if my hasWindow() then return true
336
- if attempt is 2 or attempt is 8 or attempt is 20 then
337
- try
338
- tell application "Conductor" to reopen
339
- tell application "Conductor" to activate
340
- end try
341
- end if
342
- if attempt is 12 or attempt is 28 then my dockClick()
343
- delay 0.25
344
- end repeat
345
- return false
346
- end waitForWindow
347
-
348
- on activateConductor()
349
- -- Conductor keeps running with every window closed (standard macOS: the red
350
- -- button doesn't quit), and it may not be running at all. Both used to surface
351
- -- as "Can't get window 1 of process Conductor. Invalid index." from whichever
352
- -- handler happened to run first. So: launch-or-front it ("activate" starts it
353
- -- when it isn't running), wait for a real window, then say what actually went
354
- -- wrong in words the phone can act on.
355
- -- Permissions first: a refusal reads as "no process, no window" from every
356
- -- probe below, and waiting out the patience budget to say the wrong thing
357
- -- helps nobody. A refusal is the one failure launching can't fix.
358
- set firstProbe to my windowProbe()
359
- set refusal to my refusalReason(firstProbe)
360
- if refusal is not "" then error refusal
361
- -- The lock ahead of everything else: the lock screen hides the whole session
362
- -- from Accessibility, so a locked Mac reads "no window" from every probe below
363
- -- and the ladder spends the patience budget, the Dock click and the evidence
364
- -- sweeps discovering what one read already knew — measured live (2026-08-14),
365
- -- the attempt died at the node-side ceiling as "took too long" and the phone
366
- -- never heard the word "locked". Asking here also stops "activate" from
367
- -- launching Conductor behind the lock screen, which is where the windowless
368
- -- launches that wedged it came from. A probe that already shows a window
369
- -- proves the session is unlocked (AX can't see into a locked one), so the
370
- -- happy path pays nothing. The error stays retryable on purpose: the caller's
371
- -- retry loop polls this cheaply until its deadline, so an unlock mid-budget
372
- -- lands the send by itself — that is the observed recovery, not a theory.
373
- if (item 1 of firstProbe) < 1 and my screenLocked() is "locked" then
374
- error "The Mac is locked - the lock screen hides Conductor from the relay, so nothing can be sent or pressed. Unlock the Mac and try again." & my windowEvidence()
375
- end if
376
- try
377
- tell application "Conductor" to activate
378
- on error errText
379
- error "couldn't bring Conductor to the front: " & errText
380
- end try
381
- delay 0.4
382
- -- Patience sized to what we're waiting for. An app that already shows a window
383
- -- returns from waitForWindow immediately, so the short budget only ever times a
384
- -- redraw (~4s is plenty). Zero windows gets the cold-launch budget (~9s) even
385
- -- when the process exists: the restart below is fire-and-forget, so the *next*
386
- -- attempt arrives while Conductor is still drawing its first window — "process
387
- -- exists, no window yet" — and a 4s wait there quits it again mid-launch,
388
- -- which is how one restart becomes a loop that never lets it come up. ~9s
389
- -- still fits the 28s script budget once the send's own delays are counted.
390
- set patience to 16
391
- if (item 1 of firstProbe) < 1 then set patience to 36
392
- if my waitForWindow(patience) then return
393
- -- Running, readable, and still zero windows: reopen and the Dock click have
394
- -- both drawn nothing, so a restart is the only thing left. "Open it on your
395
- -- Mac" is not advice a phone can act on — that is the whole point of this app.
396
- if (system attribute "RELAY_ALLOW_RESTART") is "1" and (item 1 of my windowProbe()) is 0 then
397
- -- Evidence before the restart tears the state down: which processes match
398
- -- Conductor and who owns a window is the one fact that separates "genuinely
399
- -- windowless" (a locked or unattended Mac) from "we're addressing the wrong
400
- -- process" — and after the relaunch it would describe the new process, not
401
- -- the state that failed. windowFailure() already carries it; this branch
402
- -- used to drop it, which made a repeating no-window failure undebuggable
403
- -- from the log alone.
404
- set ev to my windowEvidence()
405
- set lockState to my screenLocked()
406
- -- AX saying "no window" is not the same as there being no window: full
407
- -- screen on another Space and the lock screen both hide a live window from
408
- -- AX, and restarting then destroys a window the user left open (measured
409
- -- live: exactly that loop wedged Conductor for a whole evening). Only the
410
- -- window server can tell the difference, so a window it CAN see blocks the
411
- -- restart — this is a human's problem, said in words, not the relay's.
412
- if my serverWindowCount() > 0 then
413
- if lockState is "locked" then error "The Mac is locked - Conductor's window is open behind the lock screen, where the relay can't reach it. Unlock the Mac and try again." & ev
414
- error "Conductor's window exists but is not reachable - it is probably full screen on another Space. Take Conductor out of full screen, then send again." & ev
415
- end if
416
- -- No window anywhere and the lock screen up: don't restart. Every relaunch
417
- -- fired behind the lock screen in the live incident came up windowless, and
418
- -- the last one came up wedged — answering deep links, hanging every AX
419
- -- read. A restart here is not a lever, it is how the wreckage happened.
420
- if lockState is "locked" then error "The Mac is locked and Conductor has no window. Relaunching behind the lock screen is what leaves it wedged, so the relay won't - unlock the Mac and try again." & ev
421
- set restartError to my restartConductor()
422
- if restartError is not "" then error restartError & ev
423
- 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." & ev
424
- end if
425
- my requireWindow()
426
- end activateConductor
427
-
428
- on webArea()
429
- -- The webview root: the window, then four nested containers. Guarded, or a
430
- -- window that closed mid-run reappears as an "Invalid index" from a caller
431
- -- that has nothing to do with windows.
432
- my requireWindow()
433
- tell application "System Events" to tell process "Conductor"
434
- return UI element 1 of UI element 1 of UI element 1 of UI element 1 of window 1
435
- end tell
436
- end webArea
437
-
438
- on sidebarLinks()
439
- -- The sidebar rows are AXLinks named "<repo> <title> +adds -dels". They live
440
- -- two levels under the web area; collect them wherever they are at that depth.
441
- --
442
- -- `get` in front of each `whose` clause is load-bearing, and it is the whole
443
- -- cost of this handler. Without it the clause stays a *reference*, so pulling
444
- -- the elements out of it one at a time re-runs the filter against the live tree
445
- -- for each one: measured on a 45-row sidebar, walking the references cost
446
- -- 10.8s against 0.9s for the same query forced to a list once. That is paid by
447
- -- every send the deep link doesn't answer and by every status change, which has
448
- -- no path but this one.
449
- set wa to my webArea()
450
- tell application "System Events" to tell process "Conductor"
451
- set out to {}
452
- repeat with a in (UI elements of wa)
453
- try
454
- set out to out & (get UI elements of a whose role is "AXLink")
455
- repeat with b in (UI elements of a)
456
- set out to out & (get UI elements of b whose role is "AXLink")
457
- end repeat
458
- end try
459
- end repeat
460
- return out
461
- end tell
462
- end sidebarLinks
463
-
464
- on findSidebarRowOnce()
465
- set titles to my targetSidebarTitles()
466
- if (count of titles) is 0 then return missing value
467
- set repoName to system attribute "RELAY_WS_REPO"
468
- set pair to my sidebarRowsAndNames()
469
- set refs to item 1 of pair
470
- set nms to item 2 of pair
471
- set total to count of refs
472
- if total is 0 or (count of nms) is not total then return missing value
473
- repeat with candidate in titles
474
- set needle to candidate as text
475
- if needle is not "" then
476
- set hits to {}
477
- repeat with i from 1 to total
478
- set rowName to my textOrEmpty(item i of nms)
479
- if rowName contains needle then
480
- if repoName is "" or rowName contains repoName then set end of hits to i
481
- end if
482
- end repeat
483
- if (count of hits) is 1 then
484
- set idx to item 1 of hits
485
- set hit to item idx of refs
486
- -- The references and the names are two reads, so a sidebar that
487
- -- re-rendered between them (every commit rewrites a row's
488
- -- "+adds -dels") would have shifted the index under us — and pressing
489
- -- the neighbour sets the wrong workspace's status, which is the one
490
- -- failure this whole file is built to refuse. Confirm the element
491
- -- still carries the name it was picked for.
492
- if (my axName(hit)) is (my textOrEmpty(item idx of nms)) then return hit
493
- return missing value
494
- end if
495
- end if
496
- end repeat
497
- return missing value
498
- end findSidebarRowOnce
499
-
500
- on findSidebarRow()
501
- -- The workspace's own sidebar row. Only rows that are actually rendered exist
502
- -- in the AX tree (a collapsed section has none — expandSections is what opens
503
- -- those), and the title follows Conductor's precedence, so we try each
504
- -- candidate and require a *unique* hit — anything ambiguous returns nothing
505
- -- rather than guessing at a neighbour. Two passes because the only thing that
506
- -- fails the check inside is a sidebar that moved mid-read, and that is worth
507
- -- one cheap retry.
508
- set hit to my findSidebarRowOnce()
509
- if hit is not missing value then return hit
510
- return my findSidebarRowOnce()
511
- end findSidebarRow
512
-
513
- on textOrEmpty(v)
514
- try
515
- if v is missing value then return ""
516
- return v as text
517
- on error
518
- return ""
519
- end try
520
- end textOrEmpty
521
-
522
- on childRoles(el)
523
- -- Every child's role in *one* Apple event. Every AX read here is a round trip
524
- -- to another process, so asking element by element is what turned a sidebar
525
- -- scan into 15 seconds — more, by itself, than this write's whole budget.
526
- tell application "System Events" to tell process "Conductor"
527
- try
528
- return role of (UI elements of el)
529
- on error
530
- return {}
531
- end try
532
- end tell
533
- end childRoles
534
-
535
- on childRolesAndNames(el)
536
- tell application "System Events" to tell process "Conductor"
537
- try
538
- return {role of (UI elements of el), name of (UI elements of el)}
539
- on error
540
- return {{}, {}}
541
- end try
542
- end tell
543
- end childRolesAndNames
544
-
545
- on looksLikeSidebarList(el)
546
- -- The sidebar's workspace list is one flat run of section headers
547
- -- (AXStaticText) and workspace rows (AXLink), with nothing else beside them.
548
- -- That shape is the whole test, because the *transcript* hangs off the same
549
- -- web area and holds AXLinks of its own — "the element with links in it"
550
- -- would happily hand back a message bubble, and every press below would then
551
- -- land in one.
552
- set roleList to my childRoles(el)
553
- if (count of roleList) is 0 then return false
554
- set seenLink to false
555
- repeat with r in roleList
556
- set rt to my textOrEmpty(contents of r)
557
- if rt is "AXLink" then
558
- set seenLink to true
559
- else if rt is not "AXStaticText" then
560
- return false
561
- end if
562
- end repeat
563
- return seenLink
564
- end looksLikeSidebarList
565
-
566
- on sidebarList()
567
- -- Found by that shape rather than by an index, since the chrome above it
568
- -- (project pickers, the resource-usage button) comes and goes. Two levels
569
- -- deep, the same reach sidebarLinks() uses.
570
- set wa to my webArea()
571
- repeat with a in (my axKids(wa))
572
- set nodeA to contents of a
573
- if my looksLikeSidebarList(nodeA) then return nodeA
574
- repeat with b in (my axKids(nodeA))
575
- set nodeB to contents of b
576
- if my looksLikeSidebarList(nodeB) then return nodeB
577
- end repeat
578
- end repeat
579
- return missing value
580
- end sidebarList
581
-
582
- on sidebarRowsAndNames()
583
- -- Every sidebar row paired with its name. Two Apple events off the list found
584
- -- above, against one per row before.
585
- set listEl to my sidebarList()
586
- if listEl is not missing value then
587
- tell application "System Events" to tell process "Conductor"
588
- try
589
- return {(UI elements of listEl whose role is "AXLink"), name of (UI elements of listEl whose role is "AXLink")}
590
- end try
591
- end tell
592
- end if
593
- -- No list matched the shape, so Conductor has reorganised its sidebar. Fall
594
- -- back to the broad two-level sweep and one name read per row: slow, and the
595
- -- reason the fast path exists, but it assumes nothing about the layout.
596
- set refs to my sidebarLinks()
597
- set nms to {}
598
- repeat with entry in refs
599
- set end of nms to my axName(contents of entry)
600
- end repeat
601
- return {refs, nms}
602
- end sidebarRowsAndNames
603
-
604
- on looksNumeric(t)
605
- -- A folded section draws its row count where its rows would be. That number is
606
- -- a sibling of the header, not a child of it, so it has to be told apart from a
607
- -- header by hand — digits only, which no status or repo name is.
608
- if t is "" then return false
609
- repeat with c in (characters of t)
610
- if "0123456789" does not contain (c as text) then return false
611
- end repeat
612
- return true
613
- end looksNumeric
614
-
615
- on collapsedSectionNames()
616
- -- The sections someone has folded shut. A section's rows are *siblings* of
617
- -- its header, so an expanded one is followed by an AXLink and a collapsed one
618
- -- by its count, by the next header, or by nothing — and a collapsed section
619
- -- renders no rows at all, which is why its workspaces are invisible to
620
- -- Accessibility. Names, not element references: everything below re-finds the
621
- -- header when it needs it, because the list re-renders under us (a status
622
- -- change moves a row to another group) and a System Events reference is an
623
- -- index into a tree that has already changed.
624
- set listEl to my sidebarList()
625
- if listEl is missing value then return {}
626
- set pair to my childRolesAndNames(listEl)
627
- set roleList to item 1 of pair
628
- set nameList to item 2 of pair
629
- set total to count of roleList
630
- if total is 0 or (count of nameList) is not total then return {}
631
- set out to {}
632
- repeat with i from 1 to total
633
- if (my textOrEmpty(item i of roleList)) is "AXStaticText" then
634
- set nm to my textOrEmpty(item i of nameList)
635
- if nm is not "" and not (my looksNumeric(nm)) then
636
- set nextRole to ""
637
- if i < total then set nextRole to my textOrEmpty(item (i + 1) of roleList)
638
- if nextRole is not "AXLink" then set end of out to nm
639
- end if
640
- end if
641
- end repeat
642
- return out
643
- end collapsedSectionNames
644
-
645
- on sectionHeaderNamed(wanted)
646
- -- Unique or nothing, like every other lookup here: two sections cannot share
647
- -- a name, so an ambiguous hit means we are reading the wrong list.
648
- set listEl to my sidebarList()
649
- if listEl is missing value then return missing value
650
- tell application "System Events" to tell process "Conductor"
651
- try
652
- set hits to (UI elements of listEl whose role is "AXStaticText" and name is wanted)
653
- on error
654
- return missing value
655
- end try
656
- end tell
657
- if (count of hits) is 1 then return item 1 of hits
658
- return missing value
659
- end sectionHeaderNamed
660
-
661
- on toggleSection(headerName)
662
- set head to my sectionHeaderNamed(headerName)
663
- if head is missing value then return false
664
- tell application "System Events" to tell process "Conductor"
665
- try
666
- perform action "AXScrollToVisible" of head
667
- end try
668
- try
669
- perform action "AXPress" of head
670
- return true
671
- on error
672
- return false
673
- end try
674
- end tell
675
- end toggleSection
676
-
677
- on expandSections()
678
- -- Open every folded section and report which ones we opened, so the caller can
679
- -- put the sidebar back the way it found it. The header is the whole control —
680
- -- it carries AXPress and toggles the group — so this needs no chevron, which
681
- -- the webview does not expose anyway.
682
- set opened to {}
683
- repeat with nm in (my collapsedSectionNames())
684
- if my toggleSection(nm as text) then set end of opened to (nm as text)
685
- end repeat
686
- if (count of opened) > 0 then delay 0.6
687
- return opened
688
- end expandSections
689
-
690
- on restoreSections(names)
691
- -- Fold back exactly what we opened. Best-effort on purpose: this runs after
692
- -- the write has already landed, so a header that moved or vanished must not
693
- -- turn a status change that worked into a reported failure.
694
- repeat with nm in names
695
- try
696
- my toggleSection(nm as text)
697
- end try
698
- end repeat
699
- end restoreSections
700
-
701
- on focusViaSidebar()
702
- -- Press that row: no keystrokes at all, so nothing can be swallowed by a
703
- -- focused field or fire a palette *command*. Being wrong is survivable here —
704
- -- assertWorkspace re-checks before we type — and a miss falls back to the palette.
705
- set hit to my findSidebarRow()
706
- if hit is missing value then return false
707
- tell application "System Events" to tell process "Conductor"
708
- perform action "AXPress" of hit
709
- end tell
710
- delay 0.9
711
- return true
712
- end focusViaSidebar
713
-
714
- on focusViaPalette()
715
- tell application "System Events"
716
- key code 53
717
- delay 0.25
718
- keystroke "k" using {command down}
719
- delay 0.7
720
- keystroke (my targetField(2))
721
- delay 0.9
722
- key code 36
723
- delay 1.3
724
- end tell
725
- end focusViaPalette
726
-
727
- on paneAgrees()
728
- -- Nothing in the open pane contradicts the target: a chat pane is there and its
729
- -- header assertion passed (which is a no-op when there's no branch to check it
730
- -- against). This is what confirms a sidebar press landed.
731
- try
732
- set strips to my tabGroups()
733
- if (count of strips) is 0 then return false
734
- my assertWorkspace(item 1 of strips)
735
- return true
736
- end try
737
- return false
738
- end paneAgrees
739
-
740
- on atTargetWorkspace()
741
- -- "Are we already there?" — the *strict* form of the question, so it needs a
742
- -- branch to have been checked and not merely skipped. Worth asking first because
743
- -- the pane header answers in ~0.5s where reading the sidebar's rows to press one
744
- -- measured 5-7s, and ~1s since that scan went to bulk reads — still the larger
745
- -- cost of the two: a phone sends to the same workspace over and over, and
746
- -- Conductor stays where it was left. Safe as a shortcut because it *is* the
747
- -- assertion the send makes before typing anyway.
748
- if (system attribute "RELAY_WS_BRANCH") is "" then return false
749
- return my paneAgrees()
750
- end atTargetWorkspace
751
-
752
- on openViaDeepLink()
753
- -- Conductor's *own* workspace link, the one its sidebar row menu copies under
754
- -- "Copy link" (Cmd+Shift+C): one URL carrying the workspace id and the chat id,
755
- -- addressed the way the relay already reads them rather than by any label a
756
- -- release can rename. It replaces the entire focus dance below - no sidebar
757
- -- scan, no palette keystrokes, no title precedence to reproduce - and measures
758
- -- ~0.6s against the 5-10s the row press costs on a 30-workspace sidebar.
759
- --
760
- -- Two things it fixes outright rather than merely speeding up. It navigates by
761
- -- *id*, so a collapsed sidebar section (which hides the row from Accessibility
762
- -- entirely) stops mattering; and it opens the chat tab explicitly, so a pane
763
- -- left on a file view or a terminal - where the chat strip does not exist in
764
- -- the AX tree at all, and every send failed with "couldn't identify the chat
765
- -- tab strip" - comes back to the composer on its own.
766
- --
767
- -- Still fail-closed: the URL is fire-and-forget, so what counts as success is
768
- -- the same pane assertion every other path here has to pass. A Conductor
769
- -- without the route (before 0.71) ignores it silently, and a scheme macOS
770
- -- can't resolve fails the "open" outright - both fall through to the sidebar.
771
- set linkURL to system attribute "RELAY_WS_LINK"
772
- if linkURL is "" then return false
773
- try
774
- do shell script "open " & quoted form of linkURL
775
- on error
776
- return false
777
- end try
778
- -- Bounded on purpose: this poll is also the cost of *not* having the route, and
779
- -- the fallback below still has to run inside the same budget afterwards.
780
- repeat with attempt from 1 to 10
781
- delay 0.15
782
- if my paneAgrees() then return true
783
- end repeat
784
- return false
785
- end openViaDeepLink
786
-
787
- on focusWorkspace()
788
- if (my targetField(2)) is "" then return
789
- if my openViaDeepLink() then return
790
- if my atTargetWorkspace() then return
791
- if my focusViaSidebar() then
792
- if my paneAgrees() then return
793
- end if
794
- my focusViaPalette()
795
- end focusWorkspace
796
-
797
- on tabGroups()
798
- -- Level-order search, returning every tab group at the shallowest depth that
799
- -- has one (the chat strip and the terminal strip are siblings). Bounded: the
800
- -- pane sits ~5 levels down, and we must never descend into the transcript.
801
- my requireWindow()
802
- tell application "System Events" to tell process "Conductor"
803
- set level to {window 1}
804
- set depth to 0
805
- repeat while (count of level) > 0 and depth < 8
806
- set found to {}
807
- set nextLevel to {}
808
- repeat with entry in level
809
- set node to contents of entry
810
- try
811
- set found to found & (get UI elements of node whose role is "AXTabGroup")
812
- set nextLevel to nextLevel & (get UI elements of node)
813
- end try
814
- end repeat
815
- if (count of found) > 0 then return found
816
- set level to nextLevel
817
- set depth to depth + 1
818
- end repeat
819
- end tell
820
- return {}
821
- end tabGroups
822
-
823
- on stripRadios(tg)
824
- -- Every radio button in a tab strip, whether it is a direct child or sits one
825
- -- group deep, in tree order. Both shapes are live at once: the terminal strip's
826
- -- tabs are direct children, while a chat tab is wrapped in its own AXGroup with
827
- -- the file-view toggle beside it as a plain child. Reading only the direct ones
828
- -- (and stopping as soon as any turned up) is what made a chat strip look like a
829
- -- one-tab strip called "Close file view".
830
- tell application "System Events" to tell process "Conductor"
831
- set acc to {}
832
- repeat with k in (UI elements of tg)
833
- set node to contents of k
834
- if (role of node) is "AXRadioButton" then
835
- set end of acc to node
836
- else
837
- try
838
- set acc to acc & (get UI elements of node whose role is "AXRadioButton")
839
- end try
840
- end if
841
- end repeat
842
- return acc
843
- end tell
844
- end stripRadios
845
-
846
- on chatTabs(tg)
847
- -- Of those, a chat tab is the one whose accessible name is its own close action,
848
- -- "Close chat <title>". The strip's other radio button is "Close file view",
849
- -- which is not a chat and must not shift the indices this file addresses tabs
850
- -- by — off by one is how a prompt lands in the wrong conversation. Falls back to
851
- -- every radio button when none carries the prefix, so a renamed label costs the
852
- -- filter rather than emptying the strip.
853
- set radios to my stripRadios(tg)
854
- set chats to {}
855
- repeat with entry in radios
856
- set node to contents of entry
857
- if (my axName(node)) starts with "Close chat" then set end of chats to node
858
- end repeat
859
- if (count of chats) > 0 then return chats
860
- return radios
861
- end chatTabs
862
-
863
- on tabLabel(t)
864
- tell application "System Events" to tell process "Conductor"
865
- return (name of t) as text
866
- end tell
867
- end tabLabel
868
-
869
- on pickChatStrip(strips, wantCount, wantTitle)
870
- -- Score each candidate strip: a label match outweighs a tab-count match, and
871
- -- a tie means we cannot tell the chat strip from the terminal strip.
872
- set best to missing value
873
- set bestTabs to {}
874
- set bestScore to 0
875
- set tied to false
876
- repeat with entry in strips
877
- set tg to contents of entry
878
- set strip to my chatTabs(tg)
879
- set score to 0
880
- if wantCount > 0 and (count of strip) is wantCount then set score to score + 1
881
- if wantTitle is not "" then
882
- repeat with t in strip
883
- if (my tabLabel(t)) contains wantTitle then
884
- set score to score + 2
885
- exit repeat
886
- end if
887
- end repeat
888
- end if
889
- if score > bestScore then
890
- set bestScore to score
891
- set best to tg
892
- set bestTabs to strip
893
- set tied to false
894
- else if score is bestScore and score > 0 then
895
- set tied to true
896
- end if
897
- end repeat
898
- if bestScore is 0 then error "couldn't identify the chat tab strip"
899
- if tied then error "can't tell which tab strip holds the target chat"
900
- return {best, bestTabs}
901
- end pickChatStrip
902
-
903
- on lastPathSegment(s)
904
- set saved to AppleScript's text item delimiters
905
- set AppleScript's text item delimiters to "/"
906
- set parts to text items of s
907
- set AppleScript's text item delimiters to saved
908
- return item -1 of parts
909
- end lastPathSegment
910
-
911
- on paneLabels(tg, wantRole)
912
- -- Kept out of the caller's scope on purpose: inside a System Events tell,
913
- -- ordinary-looking names (tabs, count) resolve as app terms instead of vars.
914
- tell application "System Events" to tell process "Conductor"
915
- set pane to value of attribute "AXParent" of tg
916
- return (name of (UI elements of pane whose role is wantRole))
917
- end tell
918
- end paneLabels
919
-
920
- on anyContains(haystack, needle)
921
- repeat with entry in haystack
922
- try
923
- if (entry as text) contains needle then return true
924
- end try
925
- end repeat
926
- return false
927
- end anyContains
928
-
929
- on assertWorkspace(tg)
930
- -- The pane holding the chat strip also labels the open workspace: an
931
- -- AXStaticText with the branch (sans owner prefix) and a repo popup button.
932
- set wantBranch to system attribute "RELAY_WS_BRANCH"
933
- if wantBranch is "" then return
934
- set tail to my lastPathSegment(wantBranch)
935
- if not (my anyContains(my paneLabels(tg, "AXStaticText"), tail)) then
936
- error "the palette didn't land on " & tail
937
- end if
938
- set wantRepo to system attribute "RELAY_WS_REPO"
939
- if wantRepo is not "" then
940
- if not (my anyContains(my paneLabels(tg, "AXPopUpButton"), wantRepo)) then
941
- error "the palette didn't land in " & wantRepo
942
- end if
943
- end if
944
- end assertWorkspace
945
-
946
- on normalizeNewlines(s)
947
- -- "do shell script" hands back CR-delimited text; the composer reads back LF.
948
- -- Without this the verification below never matches a multi-line prompt.
949
- set saved to AppleScript's text item delimiters
950
- set AppleScript's text item delimiters to return
951
- set parts to text items of s
952
- set AppleScript's text item delimiters to linefeed
953
- set joined to parts as text
954
- set AppleScript's text item delimiters to saved
955
- return joined
956
- end normalizeNewlines
957
-
958
- on composerField()
959
- -- The composer's AXTextArea: an AXGroup named "composer" in the chat pane, one
960
- -- text area inside it. Resolved once and handed to both the fill and the send
961
- -- confirm below, because reaching it means walking the pane from the tab strip
962
- -- and that walk measured ~0.5s on a live window. Returns missing value when the
963
- -- composer isn't there at all, which is the caller's cue to fall back to pasting.
964
- set strips to my tabGroups()
965
- if (count of strips) is 0 then return missing value
966
- try
967
- tell application "System Events" to tell process "Conductor"
968
- set pane to value of attribute "AXParent" of (item 1 of strips)
969
- set composerBox to item 1 of (UI elements of pane whose name is "composer")
970
- return item 1 of (UI elements of composerBox whose role is "AXTextArea")
971
- end tell
972
- end try
973
- return missing value
974
- end composerField
975
-
976
- on composerTextOf(textBox)
977
- -- What the box holds now, or missing value when it cannot be read at all. A pane
978
- -- that re-rendered leaves the old reference pointing at a dead element, and
979
- -- "can't read it" must never be reported as "still holding the prompt": that is
980
- -- the one state that presses Enter again, and it would do so at the moment the
981
- -- box may already be empty.
982
- try
983
- tell application "System Events" to tell process "Conductor"
984
- return (value of textBox) as text
985
- end tell
986
- end try
987
- return missing value
988
- end composerTextOf
989
-
990
- on fillComposer(textBox, promptText)
991
- -- Write the prompt straight into the composer's AXTextArea instead of
992
- -- stashing the clipboard, pressing Cmd+L and pasting. AXFocused and AXValue
993
- -- are both settable, so this needs no keystrokes and no clipboard hijack.
994
- -- Returns false (→ caller falls back to pasting) if anything looks off, but
995
- -- *clears whatever it wrote first*: leaving half a prompt behind would make
996
- -- the fallback paste append to it and send a garbled prompt.
997
- if promptText is "" then return false
998
- if textBox is missing value then return false
999
- try
1000
- tell application "System Events" to tell process "Conductor"
1001
- set value of attribute "AXFocused" of textBox to true
1002
- set value of textBox to promptText
1003
- delay 0.25
1004
- if ((value of textBox) as text) does not contain promptText then
1005
- set value of textBox to ""
1006
- return false
1007
- end if
1008
- end tell
1009
- on error
1010
- try
1011
- my clearComposer(textBox)
1012
- end try
1013
- return false
1014
- end try
1015
- return true
1016
- end fillComposer
1017
-
1018
- on clearComposer(textBox)
1019
- if textBox is missing value then return
1020
- tell application "System Events" to tell process "Conductor"
1021
- set value of textBox to ""
1022
- end tell
1023
- end clearComposer
1024
-
1025
- on pressSend(queueIt)
1026
- -- Return sends, Command-Return queues the prompt behind the running answer.
1027
- -- The choice is made out here because inside a System Events tell an ordinary
1028
- -- parameter name can resolve as one of its own terms.
1029
- if queueIt then
1030
- tell application "System Events" to key code 36 using {command down}
1031
- else
1032
- tell application "System Events" to key code 36
1033
- end if
1034
- end pressSend
1035
-
1036
- on submitComposer(textBox, promptText, queueIt)
1037
- -- Press Enter, then read the composer back. Conductor consumes the draft when it
1038
- -- accepts a prompt, so a box still holding the text is a keystroke that did
1039
- -- nothing — and that read is the only receipt a run can collect about its own
1040
- -- send.
1041
- --
1042
- -- Why it earns its place: the caller's other receipt is the transcript row, which
1043
- -- costs a 6s watch (CONFIRM_WINDOW_MS in server.ts) and then a whole second run
1044
- -- through activate, focus, tab and fill. Measured from this Mac's own relay logs,
1045
- -- a send whose single fault was a first Enter Conductor ignored took ~10s from the
1046
- -- prompt appearing in the composer to it being sent, with the text sitting on
1047
- -- screen the whole way. Exactly one user row was written, so the keystroke was
1048
- -- lost rather than delayed.
1049
- --
1050
- -- Pressing again is safe *because* of the read, not in spite of it: the prompt is
1051
- -- still in the box, so nothing was consumed, so there is nothing to duplicate.
1052
- -- The one state that would break that is a Conductor which queues on Command-Return
1053
- -- and *keeps* the draft, since the box would then read as unsent after a send that
1054
- -- worked. Unverified against the live app, and the only path that could reach it is
1055
- -- Command-Return in the phone's composer (Composer.tsx, a hardware keyboard); a tap
1056
- -- always sends. Worth checking before anything else starts queueing.
1057
- -- A Conductor too busy to have handled the first Enter is also too busy to answer
1058
- -- an AX read, which blocks here rather than handing back stale text.
1059
- --
1060
- -- **Two presses, not three, and the second one is on probation.** The first
1061
- -- occurrence in the wild (2026-09-01 16:59:57) had the box survive all three, and
1062
- -- no send has ever been recorded as rescued by a later press — so the value here
1063
- -- is the *detection*, which lets the caller stop watching for a row that cannot
1064
- -- come (`sendNeverStarted` in writes.ts) rather than spending the 6s window on it.
1065
- -- Every press past the first that doesn't work is pure delay added to a send that
1066
- -- is already failing, which is why the budget shrank from 3×1.2s to 2×0.8s.
1067
- --
1068
- -- Returns the press that cleared the box, or 0 for a box that never cleared. The
1069
- -- count is the point: a rescued send is otherwise indistinguishable from one that
1070
- -- worked first time, so the failure this handler exists for would stop appearing
1071
- -- in the log the day it stopped costing 10s, and nobody could tell whether it was
1072
- -- fixed or merely hidden.
1073
- if promptText is "" then
1074
- my pressSend(queueIt)
1075
- return 1
1076
- end if
1077
- repeat with pressCount from 1 to 2
1078
- my pressSend(queueIt)
1079
- if textBox is missing value then return pressCount
1080
- repeat with tick from 1 to 4
1081
- delay 0.2
1082
- set seen to my composerTextOf(textBox)
1083
- if seen is missing value then return pressCount
1084
- if seen does not contain promptText then return pressCount
1085
- end repeat
1086
- end repeat
1087
- return 0
1088
- end submitComposer
1089
-
1090
- on pasteComposer()
1091
- -- Fallback for when the composer isn't reachable: Cmd+L focuses it (after the
1092
- -- palette, focus sits on a button, not the text box), then paste.
1093
- tell application "System Events"
1094
- set the clipboard to (do shell script "cat" & " " & quoted form of (system attribute "RELAY_PROMPT_FILE"))
1095
- keystroke "l" using {command down}
1096
- delay 0.3
1097
- keystroke "v" using {command down}
1098
- delay 0.15
1099
- end tell
1100
- end pasteComposer
1101
-
1102
- on selectChatTab()
1103
- set wantIndex to (system attribute "RELAY_TAB_INDEX") as integer
1104
- if wantIndex is 0 then return
1105
- set wantCount to (system attribute "RELAY_TAB_COUNT") as integer
1106
- set wantTitle to my targetField(1)
1107
- set strips to my tabGroups()
1108
- if (count of strips) is 0 then
1109
- -- A lone chat has no ambiguity to resolve; more than one and we must not guess.
1110
- if wantCount <= 1 then return
1111
- error "couldn't find the chat tab strip"
1112
- end if
1113
- -- Assert the workspace first: every strip lives in the same pane, so this
1114
- -- reports "wrong workspace" rather than a confusing "no chat strip".
1115
- my assertWorkspace(item 1 of strips)
1116
- set picked to my pickChatStrip(strips, wantCount, wantTitle)
1117
- set tabs to item 2 of picked
1118
- tell application "System Events" to tell process "Conductor"
1119
- set target to missing value
1120
- if wantIndex <= (count of tabs) then
1121
- set candidate to contents of (item wantIndex of tabs)
1122
- if wantTitle is "" or (name of candidate) contains wantTitle then set target to candidate
1123
- end if
1124
- if target is missing value and wantTitle is not "" then
1125
- repeat with t in tabs
1126
- if (name of t) contains wantTitle then
1127
- if target is not missing value then error "several chat tabs match " & wantTitle
1128
- set target to contents of t
1129
- end if
1130
- end repeat
1131
- end if
1132
- if target is missing value then error "chat tab " & wantIndex & " not found"
1133
- if (value of target) is not true then
1134
- perform action "AXPress" of target
1135
- delay 0.5
1136
- end if
1137
- if (value of target) is not true then error "couldn't switch to the target chat tab"
1138
- end tell
1139
- end selectChatTab
1140
-
1141
- -- The workspace toolbar's run process. This is deliberately separate from the
1142
- -- chat-strip targeting above: the terminal strip is the sibling AXTabGroup whose
1143
- -- direct radio buttons include Setup / Run / Terminal, and its task button is
1144
- -- named "Run <task>" or "Stop <task>". Conductor may add more terminal tabs, so
1145
- -- identify the strip by the Run radio rather than by its position.
1146
- on runStrip()
1147
- set matches to {}
1148
- repeat with entry in my tabGroups()
1149
- set tg to contents of entry
1150
- set hasRunTab to false
1151
- repeat with r in my stripRadios(tg)
1152
- if (my axName(contents of r)) is "Run" then set hasRunTab to true
1153
- end repeat
1154
- if hasRunTab then set end of matches to tg
1155
- end repeat
1156
- if (count of matches) is 0 then error "couldn't find Conductor's Run panel"
1157
- if (count of matches) > 1 then error "can't tell which panel holds Conductor's Run task"
1158
- return item 1 of matches
1159
- end runStrip
1160
-
1161
- on runTaskButton(tg)
1162
- set matches to {}
1163
- tell application "System Events" to tell process "Conductor"
1164
- repeat with entry in (get UI elements of tg whose role is "AXButton")
1165
- set node to contents of entry
1166
- set label to my axName(node)
1167
- if label starts with "Run " or label starts with "Stop " then set end of matches to node
1168
- end repeat
1169
- end tell
1170
- if (count of matches) is 0 then error "this workspace has no selected Run task"
1171
- if (count of matches) > 1 then error "Conductor exposed more than one selected Run task"
1172
- return item 1 of matches
1173
- end runTaskButton
1174
-
1175
- on runTaskChooser(tg)
1176
- set found to {}
1177
- tell application "System Events" to tell process "Conductor"
1178
- repeat with entry in (get UI elements of tg whose role is "AXPopUpButton")
1179
- set node to contents of entry
1180
- if (my axName(node)) is "Select task" then set end of found to node
1181
- end repeat
1182
- end tell
1183
- if (count of found) is 0 then return missing value
1184
- if (count of found) > 1 then error "Conductor exposed more than one Run task chooser"
1185
- return item 1 of found
1186
- end runTaskChooser
1187
-
1188
- on openRunTaskMenu(tg)
1189
- -- Multiple Run configs live behind a Radix pop-up. AXPress is advertised but
1190
- -- does nothing on this control; focusing it and pressing Space opens the real
1191
- -- AXMenu. Choosing an item starts the task immediately — there is no second
1192
- -- press on the primary Run button. "Configure" is Conductor's own trailing
1193
- -- command, not a task choice.
1194
- set chooser to my runTaskChooser(tg)
1195
- if chooser is missing value then error "this workspace has no Run config chooser"
1196
- tell application "System Events" to tell process "Conductor"
1197
- set value of attribute "AXFocused" of chooser to true
1198
- end tell
1199
- tell application "System Events" to key code 49
1200
- delay 0.4
1201
- set choices to {}
1202
- set wa to my webArea()
1203
- tell application "System Events" to tell process "Conductor"
1204
- repeat with m in (get UI elements of wa whose role is "AXMenu")
1205
- repeat with entry in (get UI elements of m whose role is "AXMenuItem")
1206
- set node to contents of entry
1207
- set label to my axName(node)
1208
- if label is not "" and label is not "Configure" then set end of choices to node
1209
- end repeat
1210
- end repeat
1211
- end tell
1212
- return choices
1213
- end openRunTaskMenu
1214
-
1215
- on startRunTaskNamed(tg, wantedTask)
1216
- try
1217
- set matches to {}
1218
- repeat with entry in my openRunTaskMenu(tg)
1219
- set node to contents of entry
1220
- if (my axName(node)) is wantedTask then set end of matches to node
1221
- end repeat
1222
- if (count of matches) is 0 then error "no Run config named " & wantedTask
1223
- if (count of matches) > 1 then error "several Run configs match " & wantedTask
1224
- tell application "System Events" to tell process "Conductor"
1225
- perform action "AXPress" of item 1 of matches
1226
- end tell
1227
- on error errText number errNum
1228
- tell application "System Events" to key code 53
1229
- error errText number errNum
1230
- end try
1231
- end startRunTaskNamed
1232
-
1233
- on startSoleRunTask(tg)
1234
- try
1235
- set choices to my openRunTaskMenu(tg)
1236
- if (count of choices) is 0 then error "Conductor's Run menu contains no tasks"
1237
- if (count of choices) > 1 then error "choose which Run config to start"
1238
- tell application "System Events" to tell process "Conductor"
1239
- perform action "AXPress" of item 1 of choices
1240
- end tell
1241
- on error errText number errNum
1242
- tell application "System Events" to key code 53
1243
- error errText number errNum
1244
- end try
1245
- end startSoleRunTask
1246
-
1247
- on runTaskState(btn)
1248
- set label to my axName(btn)
1249
- if label starts with "Stop " then return "running"
1250
- if label starts with "Run " then return "stopped"
1251
- error "couldn't read Conductor's Run task state"
1252
- end runTaskState
1253
-
1254
- on runTaskName(btn)
1255
- set label to my axName(btn)
1256
- if label starts with "Stop " then return text 6 thru -1 of label
1257
- if label starts with "Run " then return text 5 thru -1 of label
1258
- return label
1259
- end runTaskName
1260
-
1261
- on linkDestination(controlEl)
1262
- tell application "System Events" to tell process "Conductor"
1263
- try
1264
- return value of attribute "AXURL" of controlEl as text
1265
- on error
1266
- return ""
1267
- end try
1268
- end tell
1269
- end linkDestination
1270
-
1271
- on openRunTargets(tg)
1272
- set foundPorts to {}
1273
- set foundLinks to {}
1274
- tell application "System Events" to tell process "Conductor"
1275
- set controls to UI elements of tg
1276
- end tell
1277
- repeat with entry in controls
1278
- set node to contents of entry
1279
- tell application "System Events" to tell process "Conductor"
1280
- set nodeRole to role of node
1281
- end tell
1282
- if nodeRole is "AXButton" or nodeRole is "AXLink" then
1283
- set label to my axName(node)
1284
- if label starts with "Open" then
1285
- set destination to my linkDestination(node)
1286
- if destination starts with "http://" or destination starts with "https://" then set end of foundLinks to destination
1287
- if label starts with "Open :" then set end of foundPorts to text 7 thru -1 of label
1288
- end if
1289
- end if
1290
- end repeat
1291
- return {foundPorts, foundLinks}
1292
- end openRunTargets
1293
-
1294
- on setRunTask(wantRunning, wantedTask)
1295
- my activateConductor()
1296
- my focusWorkspace()
1297
- set tg to my runStrip()
1298
- my assertWorkspace(tg)
1299
- set btn to my runTaskButton(tg)
1300
- set beforeState to my runTaskState(btn)
1301
- set taskName to my runTaskName(btn)
1302
- set wantedState to "stopped"
1303
- if wantRunning then set wantedState to "running"
1304
- set changed to "false"
1305
- if wantRunning and wantedTask is not "" and beforeState is "running" and taskName is not wantedTask then error taskName & " is already running; stop it before starting " & wantedTask
1306
-
1307
- if beforeState is not wantedState then
1308
- if wantRunning and wantedTask is not "" then
1309
- my startRunTaskNamed(tg, wantedTask)
1310
- else if wantRunning and (my runTaskChooser(tg)) is not missing value then
1311
- -- Legacy settings carry no name to the relay. Preserve their one-tap
1312
- -- behavior only when the live menu independently confirms one choice.
1313
- my startSoleRunTask(tg)
1314
- else
1315
- tell application "System Events" to tell process "Conductor"
1316
- perform action "AXPress" of btn
1317
- end tell
1318
- end if
1319
- set changed to "true"
1320
- end if
1321
-
1322
- -- The panel re-renders after the press, invalidating every old AX handle.
1323
- -- Re-find it on each poll and require the opposite label before reporting the
1324
- -- action as complete. A started web server gets a short second window in which
1325
- -- Conductor can surface its Open control. New controls may expose their exact
1326
- -- AXURL; current builds expose only "Open :<port>". Non-server tasks return
1327
- -- with both lists empty.
1328
- set currentState to beforeState
1329
- set ports to {}
1330
- set previewURLs to {}
1331
- repeat with attempt from 1 to 44
1332
- set tg to my runStrip()
1333
- set btn to my runTaskButton(tg)
1334
- set currentState to my runTaskState(btn)
1335
- set taskName to my runTaskName(btn)
1336
- if currentState is wantedState then
1337
- if wantRunning and wantedTask is not "" and taskName is not wantedTask then error "Conductor started " & taskName & " instead of " & wantedTask
1338
- set runTargets to my openRunTargets(tg)
1339
- set ports to item 1 of runTargets
1340
- set previewURLs to item 2 of runTargets
1341
- if not wantRunning or (count of ports) > 0 or (count of previewURLs) > 0 or attempt > 20 then exit repeat
1342
- end if
1343
- delay 0.25
1344
- end repeat
1345
- if currentState is not wantedState then error "Conductor's Run task did not switch to " & wantedState
1346
- return currentState & tab & taskName & tab & changed & tab & my joinList(ports, ",") & tab & my joinList(previewURLs, ASCII character 30)
1347
- end setRunTask
1348
-
1349
- on cancelAgent()
1350
- -- Stop the answer this chat is streaming: Conductor's own "Cancel agent"
1351
- -- command, Command-Shift-Delete (key code 51). Taken from its Keyboard
1352
- -- shortcuts dialog (Command-slash, section "Chat"), not guessed - and it is
1353
- -- the only lever there is. The menu bar carries no stop command and neither
1354
- -- does the palette, and the composer's stop BUTTON is unnamed to
1355
- -- Accessibility: no title, no description, no help, no identifier, telling
1356
- -- itself apart from the send button beside it only by its Tailwind classes.
1357
- -- Which is a distinction worth nothing at the moment it matters, because
1358
- -- while a chat is working with a draft in the composer BOTH buttons are on
1359
- -- screen - stop on the left, send on the right - so "press the last button"
1360
- -- steers the running agent with whatever was half-typed instead of stopping
1361
- -- it. A keystroke can't confuse the two.
1362
- --
1363
- -- The keystroke lands wherever focus is, so this asserts the pane first, the
1364
- -- same check a send makes before typing. Cancelling the wrong agent destroys
1365
- -- work that was running fine, which is the same class of damage as landing a
1366
- -- prompt in the wrong chat and gets the same answer: error out, never guess.
1367
- set strips to my tabGroups()
1368
- if (count of strips) is 0 then error "couldn't find the chat pane to cancel in"
1369
- my assertWorkspace(item 1 of strips)
1370
- -- Command-L ("Focus chat input") first, so the shortcut is delivered inside the
1371
- -- chat view rather than to a terminal panel that had focus, where the terminal
1372
- -- would swallow it. It also parks focus somewhere harmless: the composer keeps
1373
- -- its draft, since Conductor binds this chord to Cancel agent rather than to any
1374
- -- text-deletion the field would otherwise do with it.
1375
- tell application "System Events"
1376
- keystroke "l" using {command down}
1377
- delay 0.2
1378
- key code 51 using {command down, shift down}
1379
- end tell
1380
- delay 0.3
1381
- end cancelAgent
1382
-
1383
- on closeChatTab()
1384
- -- Hide the selected chat through Conductor's own Close tab shortcut, Command-W.
1385
- -- The same chord closes a terminal tab when the terminal has focus, so Command-L
1386
- -- first is load-bearing: it moves focus into the chat whose workspace and tab were
1387
- -- asserted immediately above this handler.
1388
- set strips to my tabGroups()
1389
- if (count of strips) is 0 then error "couldn't find the chat pane to close from"
1390
- my assertWorkspace(item 1 of strips)
1391
- tell application "System Events"
1392
- keystroke "l" using {command down}
1393
- delay 0.2
1394
- keystroke "w" using {command down}
1395
- end tell
1396
-
1397
- -- Conductor asks "Close anyway" when the chat's agent is still running. The
1398
- -- server normally catches that from sessions.status before touching the UI, but
1399
- -- a turn can start in the gap between that read and this keystroke. Detect the
1400
- -- live dialog too, and never infer permission to hide a running conversation.
1401
- set confirmEl to missing value
1402
- set checks to 1
1403
- if (system attribute "RELAY_CLOSE_RUNNING") is "1" then set checks to 4
1404
- repeat with attempt from 1 to checks
1405
- delay 0.25
1406
- set confirmEl to my closeConfirmButton()
1407
- if confirmEl is not missing value then exit repeat
1408
- end repeat
1409
- if confirmEl is missing value then return "closed"
1410
- if (system attribute "RELAY_CLOSE_RUNNING") is not "1" then
1411
- tell application "System Events" to key code 53
1412
- error "the agent started working before the tab closed - closing it now needs confirmation"
1413
- end if
1414
- tell application "System Events" to tell process "Conductor"
1415
- perform action "AXPress" of confirmEl
1416
- end tell
1417
- delay 0.5
1418
- return "closed"
1419
- end closeChatTab
1420
-
1421
- on buttonsUnder(root, maxDepth)
1422
- -- Every AXButton at or below root, level-order and bounded. Depth is the whole
1423
- -- cost here: the transcript hangs off the same web area as anything Conductor
1424
- -- portals into it, so each extra level multiplies the round trips and the sweep
1425
- -- gets slower the longer the chat is. That is why the callers below pass the
1426
- -- shallow depth that fits the thing they are looking for rather than one big cap.
1427
- set acc to {}
1428
- set level to {root}
1429
- set depth to 0
1430
- tell application "System Events" to tell process "Conductor"
1431
- repeat while (count of level) > 0 and depth < maxDepth
1432
- set nextLevel to {}
1433
- repeat with entry in level
1434
- set node to contents of entry
1435
- try
1436
- set acc to acc & (get UI elements of node whose role is "AXButton")
1437
- set nextLevel to nextLevel & (get UI elements of node)
1438
- end try
1439
- end repeat
1440
- set level to nextLevel
1441
- set depth to depth + 1
1442
- end repeat
1443
- end tell
1444
- return acc
1445
- end buttonsUnder
1446
-
1447
- on shallowButtonsNamed(root, maxDepth, wanted)
1448
- -- Exact-name buttons at the first depth that contains one. Continue lives in
1449
- -- Conductor's workspace action bar, while a chat transcript hangs much deeper
1450
- -- off the same web area. Stopping at the shallowest hit both avoids walking a
1451
- -- long transcript and keeps a similarly named control inside a message from
1452
- -- impersonating the workspace action.
1453
- set level to {root}
1454
- set depth to 0
1455
- tell application "System Events" to tell process "Conductor"
1456
- repeat while (count of level) > 0 and depth < maxDepth
1457
- set found to {}
1458
- set nextLevel to {}
1459
- repeat with entry in level
1460
- set node to contents of entry
1461
- try
1462
- repeat with candidate in (get UI elements of node whose role is "AXButton")
1463
- set controlEl to contents of candidate
1464
- if (my axName(controlEl)) is wanted then set end of found to controlEl
1465
- end repeat
1466
- set nextLevel to nextLevel & (get UI elements of node)
1467
- end try
1468
- end repeat
1469
- if (count of found) > 0 then return found
1470
- set level to nextLevel
1471
- set depth to depth + 1
1472
- end repeat
1473
- end tell
1474
- return {}
1475
- end shallowButtonsNamed
1476
-
1477
- on hasSiblingButtonNamed(controlEl, wanted)
1478
- -- The live merged action is the pair Continue + Archive. Requiring that pair
1479
- -- prevents a generic Continue in a modal from impersonating it while the
1480
- -- asserted workspace pane remains visible behind the modal.
1481
- try
1482
- tell application "System Events" to tell process "Conductor"
1483
- set actionRow to value of attribute "AXParent" of controlEl
1484
- repeat with peer in (get UI elements of actionRow whose role is "AXButton")
1485
- if (my axName(contents of peer)) is wanted then return true
1486
- end repeat
1487
- end tell
1488
- end try
1489
- return false
1490
- end hasSiblingButtonNamed
1491
-
1492
- on continueWorkspaceOnNewBranch()
1493
- -- Press Conductor's own merged-workspace action. Its handler owns all of the
1494
- -- state that cannot be reproduced safely from the relay: it checks out a fresh
1495
- -- branch at the PR's target, updates the workspace row, keeps every chat, and
1496
- -- stages Branch continued.md in the selected chat. The relay never writes the
1497
- -- database; server.ts watches `workspaces.branch` for the receipt.
1498
- set strips to my tabGroups()
1499
- if (count of strips) is 0 then error "couldn't find the chat pane to continue from"
1500
- my assertWorkspace(item 1 of strips)
1501
- set found to my shallowButtonsNamed(my webArea(), 10, "Continue")
1502
- if (count of found) is 0 then error "Conductor isn't offering Continue for this workspace - it appears after its pull request is merged and refreshed"
1503
- if (count of found) > 1 then error "more than one Continue button is visible - close any dialog and try again"
1504
- set continueEl to item 1 of found
1505
- if not (my hasSiblingButtonNamed(continueEl, "Archive")) then error "couldn't verify Conductor's merged-workspace Continue action - close any dialog and try again"
1506
- set canPress to true
1507
- try
1508
- tell application "System Events" to tell process "Conductor"
1509
- set canPress to (value of attribute "AXEnabled" of continueEl) as boolean
1510
- end tell
1511
- end try
1512
- if not canPress then error "Conductor's Continue button is still busy - try again shortly"
1513
- tell application "System Events" to tell process "Conductor"
1514
- perform action "AXPress" of continueEl
1515
- end tell
1516
- delay 0.4
1517
- end continueWorkspaceOnNewBranch
1518
-
1519
- on dialogButtons()
1520
- -- Both places Conductor could be drawing a confirmation, each at the depth that
1521
- -- shape actually sits at. A real sheet hangs off the window a level or two down;
1522
- -- an in-webview dialog is a shallow portal near the top of the web area. Its
1523
- -- buttons are inside depth 3; going to 4 reaches the transcript and made an idle
1524
- -- close spend its whole 28-second budget proving that no dialog existed.
1525
- my requireWindow()
1526
- set fromWindow to {}
1527
- tell application "System Events" to tell process "Conductor"
1528
- set fromWindow to my buttonsUnder(window 1, 4)
1529
- end tell
1530
- return fromWindow & (my buttonsUnder(my webArea(), 3))
1531
- end dialogButtons
1532
-
1533
- on closeConfirmButton()
1534
- -- The running-chat dialog has exactly Cancel + "Close anyway". Require the
1535
- -- pair so an unrelated destructive dialog can never donate a similarly named
1536
- -- button to this run.
1537
- set found to missing value
1538
- set sawCancel to false
1539
- repeat with entry in my dialogButtons()
1540
- set node to contents of entry
1541
- set label to my axName(node)
1542
- if label is "Cancel" then set sawCancel to true
1543
- if label starts with "Close anyway" then set found to node
1544
- end repeat
1545
- if sawCancel then return found
1546
- return missing value
1547
- end closeConfirmButton
1548
-
1549
- on archiveConfirmButton()
1550
- -- Conductor asks before archiving a workspace whose agents are still running:
1551
- -- "Archive workspace?" over Cancel and "Stop agents and archive". Identified by
1552
- -- the *pair* - a button naming the verb, with a Cancel somewhere beside it -
1553
- -- rather than by the archive one alone, which is the same rule waitForMenuWith
1554
- -- follows: press nothing that has not proved which thing it belongs to.
1555
- -- "Unarchive" is excluded by name, since AppleScript's contains ignores case
1556
- -- and would otherwise read that as a confirmation of this.
1557
- set found to missing value
1558
- set sawCancel to false
1559
- repeat with entry in my dialogButtons()
1560
- set node to contents of entry
1561
- set label to my axName(node)
1562
- if label is "Cancel" then set sawCancel to true
1563
- if label contains "archive" and label does not contain "unarchive" then set found to node
1564
- end repeat
1565
- if sawCancel then return found
1566
- return missing value
1567
- end archiveConfirmButton
1568
-
1569
- on archiveWorkspace()
1570
- -- Put this workspace away: Conductor's own "Archive workspace", Command-Shift-A.
1571
- -- A keystroke on purpose, like cancelAgent - the sidebar row menu carries an
1572
- -- Archive item, but reaching it costs a sidebar scan plus two menu waits, and
1573
- -- this chord acts on the workspace the pane is already showing.
1574
- --
1575
- -- Which is also the danger: it archives whatever is on screen, so the pane
1576
- -- assertion is the whole guard. Archiving the wrong workspace deletes a worktree
1577
- -- and kills a turn that was running fine, so a pane that cannot be checked is
1578
- -- refused rather than aimed at (writes.ts requires the branch for that reason).
1579
- --
1580
- -- Command-L ("Focus chat input") first, exactly as cancelAgent does: the chord
1581
- -- lands wherever focus is, and a focused terminal panel swallows it.
1582
- set strips to my tabGroups()
1583
- if (count of strips) is 0 then error "couldn't find the chat pane to archive from"
1584
- my assertWorkspace(item 1 of strips)
1585
- tell application "System Events"
1586
- keystroke "l" using {command down}
1587
- delay 0.2
1588
- keystroke "a" using {command down, shift down}
1589
- end tell
1590
- -- A workspace with agents still working draws a confirmation; an idle one is
1591
- -- archived by the chord alone. Both are live, so this waits a bounded moment for
1592
- -- the dialog and treats its absence as the second case - the DB is the receipt
1593
- -- either way (server.ts watches for state 'archived'), so nothing here has to
1594
- -- decide whether the archive landed.
1595
- set confirmEl to missing value
1596
- repeat with attempt from 1 to 5
1597
- delay 0.3
1598
- set confirmEl to my archiveConfirmButton()
1599
- if confirmEl is not missing value then exit repeat
1600
- end repeat
1601
- if confirmEl is missing value then return "archived"
1602
- -- The caller has to have opted into the damage before we press this: stopping
1603
- -- other people's agents is not something to infer from a tap that said Archive.
1604
- if (system attribute "RELAY_ARCHIVE_AGENTS") is not "1" then
1605
- tell application "System Events" to key code 53
1606
- error "agents are still running in this workspace - archiving would stop them"
1607
- end if
1608
- tell application "System Events" to tell process "Conductor"
1609
- perform action "AXPress" of confirmEl
1610
- end tell
1611
- delay 0.5
1612
- return "archived"
1613
- end archiveWorkspace
1614
-
1615
- on composerControls()
1616
- set strips to my tabGroups()
1617
- if (count of strips) is 0 then error "couldn't find the composer"
1618
- tell application "System Events" to tell process "Conductor"
1619
- set pane to value of attribute "AXParent" of (item 1 of strips)
1620
- set composerBox to item 1 of (UI elements of pane whose name is "composer")
1621
- set out to {}
1622
- repeat with e in (UI elements of composerBox)
1623
- set end of out to contents of e
1624
- repeat with e2 in (UI elements of e)
1625
- set end of out to contents of e2
1626
- end repeat
1627
- end repeat
1628
- return out
1629
- end tell
1630
- end composerControls
1631
-
1632
- on controlNamed(wanted)
1633
- repeat with entry in my composerControls()
1634
- set c to contents of entry
1635
- if my tabLabel(c) is wanted then return c
1636
- end repeat
1637
- return missing value
1638
- end controlNamed
1639
-
1640
- on effortButton()
1641
- -- The effort control is a button whose *label is its current value*, so it is
1642
- -- identified by that label rather than a stable name. Claude calls its top
1643
- -- level Ultracode while Codex calls the corresponding level Ultra. Codex's
1644
- -- None state is the exception: its button has no accessible name at all, so
1645
- -- accept one (and only one) unnamed button *after* the proven model picker.
1646
- -- The main composer also contains unnamed attachment/stop buttons before that
1647
- -- picker, and pressing one of those would be worse than failing.
1648
- set levels to {"Light", "Low", "Medium", "High", "Extra high", "Max", "Ultracode", "Ultra"}
1649
- set sawModel to false
1650
- set unnamed to {}
1651
- repeat with entry in my composerControls()
1652
- set c to contents of entry
1653
- if (my axRole(c)) is "AXPopUpButton" and (my axName(c)) contains "Change agent" then
1654
- set sawModel to true
1655
- else if sawModel and (my axRole(c)) is "AXButton" then
1656
- set label to my axName(c)
1657
- if label is in levels then return c
1658
- if label is "" then set end of unnamed to c
1659
- end if
1660
- end repeat
1661
- if (count of unnamed) is 1 then return item 1 of unnamed
1662
- return missing value
1663
- end effortButton
1664
-
1665
- on setEffort(wanted)
1666
- -- Claude owns a six-value ring ending in Ultracode; Codex adds None and ends
1667
- -- in Ultra. Step around either ring once and confirm the label landed.
1668
- set btn to my effortButton()
1669
- if btn is missing value then error "couldn't find the effort control"
1670
- repeat 8 times
1671
- set currentLabel to my axName(btn)
1672
- if currentLabel is wanted or (wanted is "__UNNAMED_EFFORT__" and currentLabel is "") then return
1673
- tell application "System Events" to tell process "Conductor"
1674
- perform action "AXPress" of btn
1675
- end tell
1676
- delay 0.35
1677
- set btn to my effortButton()
1678
- if btn is missing value then error "the effort control vanished mid-cycle"
1679
- end repeat
1680
- error "couldn't set effort to " & wanted
1681
- end setEffort
1682
-
1683
- on setPlan(wanted)
1684
- set box to my controlNamed("Plan")
1685
- if box is missing value then error "couldn't find the Plan toggle"
1686
- tell application "System Events" to tell process "Conductor"
1687
- set current to ((value of box) as text)
1688
- if (wanted is "1" and current is "0") or (wanted is "0" and current is not "0") then
1689
- perform action "AXPress" of box
1690
- delay 0.4
1691
- if ((value of box) as text) is current then error "the Plan toggle didn't change"
1692
- end if
1693
- end tell
1694
- end setPlan
1695
-
1696
- on pressFast()
1697
- -- Fast has no AX state to read (its label is always "Fast"), so the caller
1698
- -- decides whether a press is needed and re-checks the DB afterwards.
1699
- set btn to my controlNamed("Fast")
1700
- if btn is missing value then error "this model has no Fast toggle"
1701
- tell application "System Events" to tell process "Conductor"
1702
- perform action "AXPress" of btn
1703
- end tell
1704
- delay 0.4
1705
- end pressFast
1706
-
1707
- on firstLine(s)
1708
- set saved to AppleScript's text item delimiters
1709
- set AppleScript's text item delimiters to linefeed
1710
- set parts to text items of s
1711
- set AppleScript's text item delimiters to saved
1712
- return item 1 of parts
1713
- end firstLine
1714
-
1715
- on pickerShowsModel(popup, wanted)
1716
- set pickerLabel to my tabLabel(popup)
1717
- if pickerLabel contains ("(" & wanted & ")") then return true
1718
- -- The menu can label a newly released model "Opus 5 NEW", while the
1719
- -- composer deliberately drops that temporary badge and says "Opus 5".
1720
- -- Treat that exact display form as the same choice; the surrounding
1721
- -- parentheses keep "Opus 5" distinct from a longer model name.
1722
- if wanted ends with " NEW" then
1723
- set baseName to text 1 thru -5 of wanted
1724
- if pickerLabel contains ("(" & baseName & ")") then return true
1725
- end if
1726
- return false
1727
- end pickerShowsModel
1728
-
1729
- on modelMenuItem(wanted)
1730
- -- Resolve one model from the picker that is already open. The menu can append
1731
- -- `NEW` to a freshly released model while the relay deliberately exposes the
1732
- -- stable name without it, so the same unique-prefix rule as setModel applies.
1733
- set chosen to missing value
1734
- set loose to {}
1735
- set wa to my webArea()
1736
- tell application "System Events" to tell process "Conductor"
1737
- repeat with m in (get UI elements of wa whose role is "AXMenu")
1738
- repeat with mi in (get UI elements of m whose role is "AXMenuItem")
1739
- set label to my firstLine(my tabLabel(mi))
1740
- if label is wanted then
1741
- set chosen to contents of mi
1742
- else if label starts with wanted then
1743
- set end of loose to contents of mi
1744
- end if
1745
- end repeat
1746
- end repeat
1747
- end tell
1748
- if chosen is missing value and (count of loose) is 1 then set chosen to item 1 of loose
1749
- if chosen is missing value then
1750
- if (count of loose) > 1 then error "several models match " & wanted
1751
- error "no model named " & wanted
1752
- end if
1753
- return chosen
1754
- end modelMenuItem
1755
-
1756
- on modelDefaultButton(menuItem)
1757
- -- Every non-default row has a child star button named "Set <model> as
1758
- -- default and select". The starred row has no button at all. Read the role and
1759
- -- label together so an unrelated control a future picker grows cannot be
1760
- -- mistaken for a global preference write.
1761
- repeat with childNode in my axKids(menuItem)
1762
- set childEl to contents of childNode
1763
- if (my axRole(childEl)) is "AXButton" and (my axName(childEl)) contains " as default" then return childEl
1764
- end repeat
1765
- return missing value
1766
- end modelDefaultButton
1767
-
1768
- on modelMenuItems()
1769
- set found to {}
1770
- set wa to my webArea()
1771
- tell application "System Events" to tell process "Conductor"
1772
- repeat with m in (get UI elements of wa whose role is "AXMenu")
1773
- set found to found & (get UI elements of m whose role is "AXMenuItem")
1774
- end repeat
1775
- end tell
1776
- return found
1777
- end modelMenuItems
1778
-
1779
- on defaultModelInOpenPicker()
1780
- -- The selected star is exposed by *removing* the star button from that row.
1781
- -- Require exactly one such row. If managed settings disable every star, or a
1782
- -- Conductor release changes this tree, failing closed is safer than reporting
1783
- -- that whichever model was requested became the user's global default.
1784
- set defaults to {}
1785
- repeat with mi in my modelMenuItems()
1786
- set menuItem to contents of mi
1787
- if (my modelDefaultButton(menuItem)) is missing value then set end of defaults to my firstLine(my tabLabel(menuItem))
1788
- end repeat
1789
- if (count of defaults) is not 1 then error "couldn't identify Conductor's default model"
1790
- return item 1 of defaults
1791
- end defaultModelInOpenPicker
1792
-
1793
- on modelPickerButton()
1794
- set popup to missing value
1795
- repeat with entry in my composerControls()
1796
- set c to contents of entry
1797
- if my tabLabel(c) contains "Change agent" then set popup to c
1798
- end repeat
1799
- if popup is missing value then error "couldn't find the model picker"
1800
- return popup
1801
- end modelPickerButton
1802
-
1803
- on openModelPicker()
1804
- set popup to my modelPickerButton()
1805
- tell application "System Events" to tell process "Conductor"
1806
- perform action "AXPress" of popup
1807
- end tell
1808
- delay 1.0
1809
- end openModelPicker
1810
-
1811
- on setDefaultModel(wanted)
1812
- -- Conductor's star is one combined action: its accessible name is "Set … as
1813
- -- default and select". Mirror that rather than pretending this only changes a
1814
- -- preference — the target chat switches model too, and the picker label is
1815
- -- read back below as the receipt for that half.
1816
- set initialPopup to my modelPickerButton()
1817
- set alreadySelected to my pickerShowsModel(initialPopup, wanted)
1818
- tell application "System Events" to tell process "Conductor"
1819
- perform action "AXPress" of initialPopup
1820
- end tell
1821
- delay 1.0
1822
- try
1823
- set chosen to my modelMenuItem(wanted)
1824
- set chosenLabel to my firstLine(my tabLabel(chosen))
1825
- set currentDefault to my defaultModelInOpenPicker()
1826
- if currentDefault is chosenLabel then
1827
- if alreadySelected then
1828
- tell application "System Events" to key code 53
1829
- return chosenLabel
1830
- end if
1831
- -- The star is already selected, but this chat is not. Its row is now
1832
- -- the remaining half of Conductor's combined action.
1833
- tell application "System Events" to tell process "Conductor"
1834
- perform action "AXPress" of chosen
1835
- end tell
1836
- delay 0.8
1837
- set selectedPopup to my modelPickerButton()
1838
- if not my pickerShowsModel(selectedPopup, chosenLabel) then error "the model didn't switch to " & chosenLabel
1839
- return chosenLabel
1840
- end if
1841
- set starButton to my modelDefaultButton(chosen)
1842
- if starButton is missing value then error "the default-model star is missing for " & chosenLabel
1843
- tell application "System Events" to tell process "Conductor"
1844
- perform action "AXPress" of starButton
1845
- end tell
1846
- delay 0.8
1847
- set selectedPopup to my modelPickerButton()
1848
- if not my pickerShowsModel(selectedPopup, chosenLabel) then error "the model didn't switch to " & chosenLabel
1849
- -- Re-open and read the star back. Pressing is fire-and-forget; the only safe
1850
- -- success is the requested row becoming the unique one with no star button.
1851
- my openModelPicker()
1852
- set verified to my modelMenuItem(wanted)
1853
- set verifiedLabel to my firstLine(my tabLabel(verified))
1854
- set currentDefault to my defaultModelInOpenPicker()
1855
- tell application "System Events" to key code 53
1856
- if currentDefault is not verifiedLabel then error "Conductor didn't mark " & verifiedLabel & " as the default model"
1857
- return verifiedLabel
1858
- on error errText number errNum
1859
- tell application "System Events" to key code 53
1860
- error errText number errNum
1861
- end try
1862
- end setDefaultModel
1863
-
1864
- on setModel(wanted)
1865
- set popup to missing value
1866
- repeat with entry in my composerControls()
1867
- set c to contents of entry
1868
- if my tabLabel(c) contains "Change agent" then set popup to c
1869
- end repeat
1870
- if popup is missing value then error "couldn't find the model picker"
1871
- if my pickerShowsModel(popup, wanted) then return
1872
- tell application "System Events" to tell process "Conductor"
1873
- perform action "AXPress" of popup
1874
- end tell
1875
- delay 1.0
1876
- -- The relay removes a temporary `NEW` badge before it sends this label back,
1877
- -- so a prefix match resolves that menu item. It is accepted only when unique:
1878
- -- "Sonnet 4.6" would
1879
- -- otherwise also match "Sonnet 4.6 1M"), which must fail rather than guess.
1880
- set chosen to missing value
1881
- set loose to {}
1882
- set wa to my webArea()
1883
- tell application "System Events" to tell process "Conductor"
1884
- repeat with m in (get UI elements of wa whose role is "AXMenu")
1885
- repeat with mi in (get UI elements of m whose role is "AXMenuItem")
1886
- set label to my firstLine(my tabLabel(mi))
1887
- if label is wanted then
1888
- set chosen to contents of mi
1889
- else if label starts with wanted then
1890
- set end of loose to contents of mi
1891
- end if
1892
- end repeat
1893
- end repeat
1894
- end tell
1895
- if chosen is missing value and (count of loose) is 1 then set chosen to item 1 of loose
1896
- if chosen is missing value then
1897
- tell application "System Events" to key code 53
1898
- if (count of loose) > 1 then error "several models match " & wanted
1899
- error "no model named " & wanted
1900
- end if
1901
- set chosenLabel to my firstLine(my tabLabel(chosen))
1902
- tell application "System Events" to tell process "Conductor"
1903
- perform action "AXPress" of chosen
1904
- end tell
1905
- delay 0.8
1906
- set popup to missing value
1907
- repeat with entry in my composerControls()
1908
- set c to contents of entry
1909
- if my tabLabel(c) contains "Change agent" then set popup to c
1910
- end repeat
1911
- if popup is missing value then error "the model picker vanished"
1912
- if not my pickerShowsModel(popup, chosenLabel) then error "the model didn't switch to " & chosenLabel
1913
- end setModel
1914
-
1915
- on listModels()
1916
- -- Enumerate the picker rather than hard-coding a model list that would rot on
1917
- -- every Conductor release. The first tagged line carries the unique starred
1918
- -- row, followed by the ordinary labels. Opens the menu, reads, closes it again.
1919
- my openModelPicker()
1920
- try
1921
- set labels to {}
1922
- repeat with mi in my modelMenuItems()
1923
- set end of labels to my firstLine(my tabLabel(mi))
1924
- end repeat
1925
- set defaultLabel to my defaultModelInOpenPicker()
1926
- tell application "System Events" to key code 53
1927
- set saved to AppleScript's text item delimiters
1928
- set AppleScript's text item delimiters to linefeed
1929
- set joined to labels as text
1930
- set AppleScript's text item delimiters to saved
1931
- return "__CONDUCTOR_DEFAULT_MODEL__" & tab & defaultLabel & linefeed & joined
1932
- on error errText number errNum
1933
- tell application "System Events" to key code 53
1934
- error errText number errNum
1935
- end try
1936
- end listModels
1937
-
1938
- on applyAgentOptions()
1939
- set wantEffort to system attribute "RELAY_SET_EFFORT"
1940
- set wantPlan to system attribute "RELAY_SET_PLAN"
1941
- set wantFast to system attribute "RELAY_SET_FAST"
1942
- set wantModel to system attribute "RELAY_SET_MODEL"
1943
- if wantModel is not "" then my setModel(wantModel)
1944
- if wantEffort is not "" then my setEffort(wantEffort)
1945
- if wantPlan is not "" then my setPlan(wantPlan)
1946
- if wantFast is "1" then my pressFast()
1947
- end applyAgentOptions
1948
-
1949
- on axRole(el)
1950
- tell application "System Events" to tell process "Conductor"
1951
- try
1952
- return role of el
1953
- on error
1954
- return ""
1955
- end try
1956
- end tell
1957
- end axRole
1958
-
1959
- on axKids(el)
1960
- tell application "System Events" to tell process "Conductor"
1961
- try
1962
- return UI elements of el
1963
- on error
1964
- return {}
1965
- end try
1966
- end tell
1967
- end axKids
1968
-
1969
- on menusUnder(root, maxDepth)
1970
- -- Every AXMenu at or below root, breadth-first and bounded. The status
1971
- -- submenu opens *nested inside* the row menu rather than beside it, so a
1972
- -- search that stops at the first hit (the way setModel scans the web area's
1973
- -- direct children) never sees it.
1974
- set level to {root}
1975
- set found to {}
1976
- set depth to 0
1977
- repeat while (count of level) > 0 and depth < maxDepth
1978
- set nextLevel to {}
1979
- repeat with entry in level
1980
- set node to contents of entry
1981
- if (my axRole(node)) is "AXMenu" then set end of found to node
1982
- set nextLevel to nextLevel & (my axKids(node))
1983
- end repeat
1984
- set level to nextLevel
1985
- set depth to depth + 1
1986
- end repeat
1987
- return found
1988
- end menusUnder
1989
-
1990
- on axName(el)
1991
- -- Guarded: a menu re-renders under us, and reading the name of an element that
1992
- -- has since gone throws an object-specifier error from wherever we happened to be.
1993
- tell application "System Events" to tell process "Conductor"
1994
- try
1995
- set n to name of el
1996
- if n is missing value then return ""
1997
- return n as text
1998
- on error
1999
- return ""
2000
- end try
2001
- end tell
2002
- end axName
2003
-
2004
- on menuItemNamed(m, wanted)
2005
- repeat with mi in (my axKids(m))
2006
- set node to contents of mi
2007
- if (my axRole(node)) is "AXMenuItem" and (my axName(node)) is wanted then return node
2008
- end repeat
2009
- return missing value
2010
- end menuItemNamed
2011
-
2012
- on waitForMenuWith(root, maxDepth, itemName, attempts)
2013
- -- Identify the menu by an item it *contains*, never as "the first AXMenu on
2014
- -- screen": a model picker left open, or a menu from the run before, is also an
2015
- -- AXMenu, and picking it produced "no Set status item" on a menu that had never
2016
- -- been the workspace's. A menu is drawn by the webview, not by us, so how long
2017
- -- it takes varies with what Conductor is busy doing — poll rather than guess
2018
- -- one delay; the common case costs a single sweep.
2019
- repeat with i from 1 to attempts
2020
- repeat with entry in my menusUnder(root, maxDepth)
2021
- set node to contents of entry
2022
- if (my menuItemNamed(node, itemName)) is not missing value then return node
2023
- end repeat
2024
- delay 0.5
2025
- end repeat
2026
- return missing value
2027
- end waitForMenuWith
2028
-
2029
- on dismissMenus()
2030
- -- Two escapes: one for the submenu, one for the row menu. Leaving either open
2031
- -- would swallow the next run's keystrokes.
2032
- tell application "System Events"
2033
- key code 53
2034
- delay 0.25
2035
- key code 53
2036
- end tell
2037
- end dismissMenus
2038
-
2039
- on pressStatusMenu(rowEl, wanted)
2040
- -- Scroll it into view first. A row that exists in the AX tree but sits outside
2041
- -- the sidebar's visible strip accepts AXShowMenu and draws nothing — which is
2042
- -- exactly what happens right after a status change moves it to another group.
2043
- tell application "System Events" to tell process "Conductor"
2044
- try
2045
- perform action "AXScrollToVisible" of rowEl
2046
- end try
2047
- end tell
2048
- delay 0.4
2049
- -- Clear anything already open (a picker, or a menu a previous run left behind)
2050
- -- so the sweep below can only match the one this right-click draws.
2051
- tell application "System Events" to key code 53
2052
- delay 0.3
2053
- tell application "System Events" to tell process "Conductor"
2054
- perform action "AXShowMenu" of rowEl
2055
- end tell
2056
- -- Depth 4, not the whole tree: the row menu is a portal near the top of the web
2057
- -- area, and the *transcript* hangs off that same root — a deep sweep walks every
2058
- -- message bubble and costs more than the rest of this write put together (it
2059
- -- grows with the conversation, so it degrades as you use the app).
2060
- set rowMenu to my waitForMenuWith(my webArea(), 4, "Set status", 6)
2061
- if rowMenu is missing value then
2062
- my dismissMenus()
2063
- error "the workspace's menu didn't open — or it no longer offers Set status"
2064
- end if
2065
- set statusItem to my menuItemNamed(rowMenu, "Set status")
2066
- tell application "System Events" to tell process "Conductor"
2067
- perform action "AXPress" of statusItem
2068
- end tell
2069
- -- The submenu is an AXMenu nested a few levels *inside* the row menu (Conductor
2070
- -- expands it in place rather than opening a sibling), so search from that menu —
2071
- -- searching the web area again would re-walk the world. Two things make this a
2072
- -- poll rather than a read: the row-menu handle goes stale across the expansion
2073
- -- (it reports no children instead of erroring), so it is re-found each pass; and
2074
- -- the submenu's *items* arrive a beat after the submenu itself, so the thing we
2075
- -- wait for is the wanted status, not the container that will hold it.
2076
- set subMenu to missing value
2077
- repeat with attempt from 1 to 8
2078
- set liveMenu to my waitForMenuWith(my webArea(), 4, "Set status", 1)
2079
- if liveMenu is not missing value then
2080
- set subMenu to my waitForMenuWith(liveMenu, 5, wanted, 1)
2081
- end if
2082
- if subMenu is not missing value then exit repeat
2083
- delay 0.4
2084
- end repeat
2085
- if subMenu is missing value then
2086
- my dismissMenus()
2087
- error "Conductor never offered a status called " & wanted
2088
- end if
2089
- set choice to my menuItemNamed(subMenu, wanted)
2090
- tell application "System Events" to tell process "Conductor"
2091
- perform action "AXPress" of choice
2092
- end tell
2093
- delay 0.6
2094
- end pressStatusMenu
2095
-
2096
- on setWorkspaceStatus()
2097
- -- Conductor has no menu-bar or palette command for this, so the only lever is
2098
- -- the sidebar row's own context menu (Mark as unread / Pin / Set status /
2099
- -- Rename / Copy link / Archive). Right-clicking the row needs no focus change,
2100
- -- so unlike a send this never disturbs which workspace is on screen.
2101
- set wanted to system attribute "RELAY_SET_STATUS"
2102
- if wanted is "" then error "no status requested"
2103
- set theRow to my findSidebarRow()
2104
- set opened to {}
2105
- if theRow is missing value then
2106
- -- A folded section renders no rows, so the row may simply not be in the tree
2107
- -- yet. There is no palette command to fall back to, so open the sections
2108
- -- ourselves and look again — then fold them back, since the one thing this
2109
- -- write promises is that the sidebar is where you left it.
2110
- set opened to my expandSections()
2111
- if (count of opened) > 0 then set theRow to my findSidebarRow()
2112
- end if
2113
- if theRow is missing value then
2114
- my restoreSections(opened)
2115
- error "couldn't find this workspace in the sidebar"
2116
- end if
2117
- try
2118
- my pressStatusMenu(theRow, wanted)
2119
- on error errMsg
2120
- my restoreSections(opened)
2121
- error errMsg
2122
- end try
2123
- my restoreSections(opened)
2124
- end setWorkspaceStatus
2125
-
2126
- (* -- Instant Hotspot ----------------------------------------------------------
2127
- The funnel watchdog's last resort when this Mac has no route: press the row
2128
- for a personal hotspot in Control Center's Wi-Fi popover. networksetup can
2129
- only join a network that is broadcasting, and a personal hotspot usually
2130
- is not - the row in the Wi-Fi menu is fed by Continuity over Bluetooth, and
2131
- pressing it asks the phone to wake its hotspot, exactly like a human
2132
- clicking the Wi-Fi menu. Everything here targets process "ControlCenter",
2133
- not Conductor - same Accessibility grant, different process, which is why
2134
- the refusal mapping below skips refusalReason's -1712 branch (its words are
2135
- about a wedged Conductor).
2136
- Addressed by AXIdentifier throughout ("com.apple.menuextra.wifi",
2137
- "wifi-network-<name>", "wifi-header") - the items' names are missing value
2138
- and any visible text is localized, the identifiers are neither (measured
2139
- live on macOS 26.5, 2026-08). A "whose value of attribute ..." filter fails
2140
- wholesale (-1728) when any element lacks the attribute - several menu bar
2141
- items do - so every lookup here walks with a per-item try instead.
2142
- The SE-terms trap at the top of CLAUDE.md's applescript section bit this
2143
- code through a HANDLER PARAMETER: findAxId's first argument was once named
2144
- `container`, a System Events dictionary word, so inside the tell it
2145
- resolved as the term and the walk found nothing - through an error-eating
2146
- try, so a provably open popover read as "no popover" for a whole debugging
2147
- session while every direct read worked. osacompile accepts it, the handler
2148
- check can't see it. Keep every identifier in these handlers boring and
2149
- un-dictionary-like.
2150
- The menu bar item is a TOGGLE: the same press opens and closes the
2151
- popover, so nothing here presses it without first checking which state the
2152
- popover is in (wifiPopoverWindow), and an already-open popover aborts the
2153
- run - a human may be mid-gesture in it, and their next click kills an
2154
- automated run anyway (contention looks exactly like the status-row menu's:
2155
- fails while someone uses the Mac, clean when it is unattended). *)
2156
-
2157
- on wifiMenuBarItem()
2158
- try
2159
- with timeout of 6 seconds
2160
- tell application "System Events" to tell process "ControlCenter"
2161
- repeat with mi in menu bar items of menu bar 1
2162
- try
2163
- if (value of attribute "AXIdentifier" of mi) is "com.apple.menuextra.wifi" then return contents of mi
2164
- end try
2165
- end repeat
2166
- end tell
2167
- end timeout
2168
- on error errText number errNum
2169
- if errNum is not -1712 then
2170
- set refusal to my refusalReason({-1, errNum, errText})
2171
- if refusal is not "" then error refusal
2172
- end if
2173
- error "reading the menu bar failed (" & errNum & "): " & errText
2174
- end try
2175
- error "the Wi-Fi control is not in the menu bar - turn on Show in Menu Bar for Wi-Fi in System Settings > Control Center"
2176
- end wifiMenuBarItem
2177
-
2178
- on findAxId(parentEl, wantedId, depth)
2179
- -- Depth-capped even though the popover is small: the cap is what keeps a
2180
- -- macOS reshuffle of this tree from turning one lookup into a full crawl.
2181
- -- `parentEl` is deliberately not `container` - see the section comment.
2182
- tell application "System Events"
2183
- try
2184
- set kids to UI elements of parentEl
2185
- on error
2186
- return missing value
2187
- end try
2188
- end tell
2189
- repeat with el in kids
2190
- try
2191
- tell application "System Events"
2192
- if (value of attribute "AXIdentifier" of el) is wantedId then return contents of el
2193
- end tell
2194
- end try
2195
- if depth > 1 then
2196
- set hit to my findAxId(el, wantedId, depth - 1)
2197
- if hit is not missing value then return hit
2198
- end if
2199
- end repeat
2200
- return missing value
2201
- end findAxId
2202
-
2203
- on wifiPopoverWindow()
2204
- -- The popover is whichever ControlCenter window carries the "wifi-header"
2205
- -- id. Existence is read fresh on every call: pressing a network row can
2206
- -- close the popover by itself, and the menu bar item is a toggle, so a
2207
- -- blind second press would reopen what just closed.
2208
- tell application "System Events" to tell process "ControlCenter"
2209
- try
2210
- set wins to windows
2211
- on error
2212
- return missing value
2213
- end try
2214
- end tell
2215
- repeat with w in wins
2216
- if my findAxId(w, "wifi-header", 3) is not missing value then return contents of w
2217
- end repeat
2218
- return missing value
2219
- end wifiPopoverWindow
2220
-
2221
- on closeWifiPopover(wifiItem)
2222
- if my wifiPopoverWindow() is not missing value then
2223
- try
2224
- tell application "System Events" to perform action "AXPress" of wifiItem
2225
- end try
2226
- end if
2227
- end closeWifiPopover
2228
-
2229
- on joinInstantHotspot()
2230
- -- The name arrives via a file, like RELAY_PROMPT_FILE: do shell script
2231
- -- output is decoded as UTF-8, so a name with non-ASCII letters survives
2232
- -- where a system attribute read may not. AppleScript's default nonliteral
2233
- -- text comparison then treats composed and decomposed accents as equal,
2234
- -- which matters because the AX identifier uses whatever normalization
2235
- -- macOS chose.
2236
- set hotspotName to do shell script "cat " & quoted form of (system attribute "RELAY_HOTSPOT_FILE")
2237
- if hotspotName is "" then error "no hotspot name provided"
2238
- -- The lock screen hides the session from Accessibility, so behind it this
2239
- -- press has nothing to land on. Named up front, in the same words the send
2240
- -- path uses, instead of surfacing later as a popover that never opened.
2241
- if my screenLocked() is "locked" then error "The Mac is locked - the Wi-Fi menu can't be pressed until it unlocks"
2242
- set wifiItem to my wifiMenuBarItem()
2243
- if my wifiPopoverWindow() is not missing value then error "the Wi-Fi popover is already open - someone may be using it, so nothing was pressed"
2244
- tell application "System Events" to perform action "AXPress" of wifiItem
2245
- -- The popover renders in its own time (measured 1-2s), and a human's click
2246
- -- anywhere on the Mac closes it, so poll rather than sleep once.
2247
- set pop to missing value
2248
- repeat 8 times
2249
- delay 0.5
2250
- set pop to my wifiPopoverWindow()
2251
- if pop is not missing value then exit repeat
2252
- end repeat
2253
- if pop is missing value then error "pressing the Wi-Fi control opened no popover - someone clicking on the Mac closes it the moment it opens (contention, not a fault); otherwise Control Center is not answering"
2254
- set rowEl to missing value
2255
- repeat 4 times
2256
- set rowEl to my findAxId(pop, "wifi-network-" & hotspotName, 6)
2257
- if rowEl is not missing value then exit repeat
2258
- delay 0.5
2259
- end repeat
2260
- if rowEl is missing value then
2261
- my closeWifiPopover(wifiItem)
2262
- error quote & hotspotName & quote & " is not in the Wi-Fi menu - the iPhone is out of Bluetooth range, has Bluetooth off, or Personal Hotspot is off in its Settings"
2263
- end if
2264
- -- The row is a toggle too: value 1 means already connected, and pressing
2265
- -- it then would DISCONNECT - the one outcome worse than doing nothing.
2266
- -- Only a read of exactly 1 skips the press; an unreadable value presses.
2267
- set rowValue to 0
2268
- try
2269
- tell application "System Events" to set rowValue to value of rowEl
2270
- end try
2271
- if rowValue is 1 then
2272
- my closeWifiPopover(wifiItem)
2273
- return "already-connected"
2274
- end if
2275
- tell application "System Events" to perform action "AXPress" of rowEl
2276
- delay 1
2277
- my closeWifiPopover(wifiItem)
2278
- return "pressed"
2279
- end joinInstantHotspot