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.
- package/README.md +10 -8
- package/dist/assets/{PierrePatch-D3_YBIAr.js → PierrePatch-D4YbKD8Q.js} +1 -1
- package/dist/assets/index-BRzsSW8g.css +1 -0
- package/dist/assets/index-BaS6qXUW.js +65 -0
- package/dist/index.html +2 -2
- package/dist/push-sw.js +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/scripts/nosleep-setup.js +2 -2
- package/dist-node/scripts/nosleep.js +3 -3
- package/dist-node/scripts/service/commands.js +32 -0
- package/dist-node/scripts/service/configuration.js +205 -0
- package/dist-node/scripts/service/environment.js +59 -0
- package/dist-node/scripts/service/flags.js +45 -0
- package/dist-node/scripts/service/installation.js +91 -0
- package/dist-node/scripts/service/launch-agent.js +134 -0
- package/dist-node/scripts/service/network.js +244 -0
- package/dist-node/scripts/service/presentation.js +101 -0
- package/dist-node/scripts/service/voice.js +113 -0
- package/dist-node/scripts/service.js +5 -980
- package/dist-node/src/{agent-config.js → agents/agent-config.js} +1 -1
- package/dist-node/src/{model-cache.js → agents/model-cache.js} +1 -1
- package/dist-node/src/{roles.js → agents/roles.js} +3 -3
- package/dist-node/src/db.js +1 -1
- package/dist-node/src/{parked.js → delivery/parked.js} +1 -1
- package/dist-node/src/{dev-server.js → dev-server/controller.js} +8 -291
- package/dist-node/src/dev-server/ports.js +63 -0
- package/dist-node/src/dev-server/processes.js +73 -0
- package/dist-node/src/dev-server/proxy.js +158 -0
- package/dist-node/src/{run-activity.js → dev-server/run-activity.js} +2 -2
- package/dist-node/src/dev-server/types.js +1 -0
- package/dist-node/src/{file-preview.js → files/file-preview.js} +1 -1
- package/dist-node/src/{change-stats.js → git/change-stats.js} +1 -1
- package/dist-node/src/{git.js → git/diff.js} +1 -1
- package/dist-node/src/{autoupdate.js → host/autoupdate.js} +1 -1
- package/dist-node/src/{funnel-watchdog.js → host/funnel-watchdog.js} +4 -4
- package/dist-node/src/{logbuf.js → host/logbuf.js} +1 -1
- package/dist-node/src/{nosleep-helper.js → host/nosleep-helper.js} +1 -1
- package/dist-node/src/{tailscale.js → host/tailscale.js} +1 -1
- package/dist-node/src/http/router-types.js +2 -0
- package/dist-node/src/http/router.js +78 -0
- package/dist-node/src/http/routes/create-workspace.js +90 -0
- package/dist-node/src/http/routes/files.js +53 -0
- package/dist-node/src/http/routes/prompts.js +363 -0
- package/dist-node/src/http/routes/sessions.js +279 -0
- package/dist-node/src/http/routes/state.js +186 -0
- package/dist-node/src/http/routes/system.js +220 -0
- package/dist-node/src/http/routes/voice.js +117 -0
- package/dist-node/src/http/routes/workflows.js +248 -0
- package/dist-node/src/http/routes/workspaces.js +267 -0
- package/dist-node/src/http/services/base.js +252 -0
- package/dist-node/src/http/services/delegations.js +301 -0
- package/dist-node/src/http/services/delivery.js +433 -0
- package/dist-node/src/http/services/files.js +199 -0
- package/dist-node/src/http/services/mcp.js +94 -0
- package/dist-node/src/http/services/responses.js +120 -0
- package/dist-node/src/http/services/voice.js +147 -0
- package/dist-node/src/http/services/workflow-probes.js +215 -0
- package/dist-node/src/http/services/workflow-state.js +195 -0
- package/dist-node/src/http/services/workflow.js +366 -0
- package/dist-node/src/http/services.js +35 -0
- package/dist-node/src/mcp/arguments.js +14 -0
- package/dist-node/src/mcp/dispatcher.js +56 -0
- package/dist-node/src/mcp/formatters.js +110 -0
- package/dist-node/src/mcp/protocol.js +7 -0
- package/dist-node/src/mcp/registry.js +66 -0
- package/dist-node/src/mcp/tools/agent-options.js +122 -0
- package/dist-node/src/mcp/tools/chat-actions.js +194 -0
- package/dist-node/src/mcp/tools/chats.js +194 -0
- package/dist-node/src/mcp/tools/delegation.js +135 -0
- package/dist-node/src/mcp/tools/system.js +135 -0
- package/dist-node/src/mcp/tools/voice.js +136 -0
- package/dist-node/src/mcp/tools/workspaces.js +259 -0
- package/dist-node/src/mcp/types.js +1 -0
- package/dist-node/src/mcp.js +6 -4
- package/dist-node/src/{notify.js → notifications/notify.js} +5 -5
- package/dist-node/src/{webpush.js → notifications/webpush.js} +1 -1
- package/dist-node/src/orchestration/delegation/codec.js +201 -0
- package/dist-node/src/{delegation-intake.js → orchestration/delegation/intake.js} +2 -2
- package/dist-node/src/{delegation-prompt.js → orchestration/delegation/prompt.js} +1 -1
- package/dist-node/src/orchestration/delegation/queue.js +255 -0
- package/dist-node/src/orchestration/delegation/store.js +111 -0
- package/dist-node/src/orchestration/delegation/types.js +2 -0
- package/dist-node/src/orchestration/persistence/capabilities.js +109 -0
- package/dist-node/src/orchestration/persistence/codecs.js +148 -0
- package/dist-node/src/orchestration/persistence/connection.js +205 -0
- package/dist-node/src/orchestration/persistence/db.js +101 -0
- package/dist-node/src/orchestration/persistence/effect-recovery.js +227 -0
- package/dist-node/src/orchestration/persistence/effect-transitions.js +157 -0
- package/dist-node/src/orchestration/persistence/effects.js +109 -0
- package/dist-node/src/orchestration/persistence/errors.js +39 -0
- package/dist-node/src/orchestration/persistence/events.js +29 -0
- package/dist-node/src/orchestration/persistence/idempotency.js +82 -0
- package/dist-node/src/orchestration/persistence/job-attempts.js +114 -0
- package/dist-node/src/orchestration/persistence/jobs.js +159 -0
- package/dist-node/src/orchestration/persistence/projections.js +190 -0
- package/dist-node/src/orchestration/persistence/quarantine.js +46 -0
- package/dist-node/src/orchestration/persistence/records.js +115 -0
- package/dist-node/src/orchestration/persistence/relays.js +78 -0
- package/dist-node/src/orchestration/persistence/runs.js +156 -0
- package/dist-node/src/{orchestration-schema.js → orchestration/persistence/schema.js} +1 -1
- package/dist-node/src/orchestration/persistence/types.js +1 -0
- package/dist-node/src/orchestration/persistence/ui-lease.js +197 -0
- package/dist-node/src/orchestration/persistence/values.js +114 -0
- package/dist-node/src/orchestration/workflow/commands.js +309 -0
- package/dist-node/src/orchestration/workflow/coordinator.js +28 -0
- package/dist-node/src/orchestration/workflow/effect-recovery.js +125 -0
- package/dist-node/src/orchestration/workflow/effects.js +307 -0
- package/dist-node/src/orchestration/workflow/errors.js +13 -0
- package/dist-node/src/orchestration/workflow/helpers.js +154 -0
- package/dist-node/src/orchestration/workflow/job-dispatch.js +312 -0
- package/dist-node/src/orchestration/workflow/job-results.js +280 -0
- package/dist-node/src/{workflow.js → orchestration/workflow/prompts.js} +2 -2
- package/dist-node/src/orchestration/workflow/root.js +293 -0
- package/dist-node/src/orchestration/workflow/start.js +99 -0
- package/dist-node/src/orchestration/workflow/state.js +105 -0
- package/dist-node/src/orchestration/workflow/types.js +1 -0
- package/dist-node/src/orchestration/workflow/wake.js +164 -0
- package/dist-node/src/prefs.js +1 -1
- package/dist-node/src/{background-tasks.js → reads/background-tasks.js} +1 -1
- package/dist-node/src/reads/messages.js +362 -0
- package/dist-node/src/reads/repository.js +87 -0
- package/dist-node/src/reads/sessions.js +165 -0
- package/dist-node/src/reads/types.js +1 -0
- package/dist-node/src/reads/workspaces.js +230 -0
- package/dist-node/src/reads/worktrees.js +45 -0
- package/dist-node/src/routes.js +5 -5
- package/dist-node/src/{search.js → search/coordinator.js} +4 -4
- package/dist-node/src/{search-worker.js → search/worker.js} +3 -3
- package/dist-node/src/server.js +14 -3930
- package/dist-node/src/settings.js +1 -1
- package/dist-node/src/shared.js +10 -10
- package/dist-node/src/{transcript.js → transcript/parser.js} +4 -4
- package/dist-node/src/{tool-usage-worker.js → usage/tool-usage-worker.js} +1 -1
- package/dist-node/src/{tool-usage.js → usage/tool-usage.js} +1 -1
- package/dist-node/src/voice/brief.js +22 -50
- package/dist-node/src/voice/context.js +1 -1
- package/dist-node/src/voice/dates.js +38 -0
- package/dist-node/src/voice/history.js +14 -5
- package/dist-node/src/voice/prompt.js +10 -9
- package/dist-node/src/voice/recall.js +115 -0
- package/dist-node/src/{speech.js → voice/speech.js} +1 -1
- package/dist-node/src/voice/tools.js +92 -3
- package/dist-node/src/voice/webrtc.js +1 -1
- package/dist-node/src/wire.js +2 -2
- package/dist-node/src/writes/actuator.js +124 -0
- package/dist-node/src/writes/agent-options.js +151 -0
- package/dist-node/src/writes/applescript/agent-options.applescript +334 -0
- package/dist-node/src/writes/applescript/chat-targeting.applescript +149 -0
- package/dist-node/src/writes/applescript/composer.applescript +195 -0
- package/dist-node/src/writes/applescript/hotspot.applescript +155 -0
- package/dist-node/src/writes/applescript/lifecycle.applescript +266 -0
- package/dist-node/src/writes/applescript/run-tasks.applescript +208 -0
- package/dist-node/src/writes/applescript/source.js +52 -0
- package/dist-node/src/writes/applescript/window.applescript +436 -0
- package/dist-node/src/writes/applescript/workspace-status.applescript +177 -0
- package/dist-node/src/writes/applescript/workspace-targeting.applescript +359 -0
- package/dist-node/src/writes/chats.js +160 -0
- package/dist-node/src/writes/guards.js +77 -0
- package/dist-node/src/writes/runner.js +67 -0
- package/dist-node/src/{sidecar.js → writes/sidecar.js} +1 -1
- package/dist-node/src/writes/system.js +81 -0
- package/dist-node/src/writes/targeting.js +121 -0
- package/dist-node/src/writes/types.js +1 -0
- package/dist-node/src/writes/ui-lock.js +229 -0
- package/dist-node/src/writes/workspaces.js +233 -0
- package/docs/voice-setup.md +34 -12
- package/package.json +2 -2
- package/dist/assets/index-ByTwVInL.css +0 -1
- package/dist/assets/index-LAm9X7wv.js +0 -65
- package/dist-node/src/conductor.applescript +0 -2279
- package/dist-node/src/delegations.js +0 -557
- package/dist-node/src/mcp-tools.js +0 -1319
- package/dist-node/src/orchestration-db.js +0 -2186
- package/dist-node/src/reads.js +0 -787
- package/dist-node/src/workflow-coordinator.js +0 -2119
- package/dist-node/src/writes.js +0 -1223
- /package/dist-node/src/{conductor-settings.js → agents/conductor-settings.js} +0 -0
- /package/dist-node/src/{firstprompt.js → delivery/firstprompt.js} +0 -0
- /package/dist-node/src/{sendonce.js → delivery/sendonce.js} +0 -0
- /package/dist-node/src/{preview-urls.js → dev-server/preview-urls.js} +0 -0
- /package/dist-node/src/{run-configs.js → dev-server/run-configs.js} +0 -0
- /package/dist-node/src/{attachments.js → files/attachments.js} +0 -0
- /package/dist-node/src/{icons.js → files/icons.js} +0 -0
- /package/dist-node/src/{staged-attachments.js → files/staged-attachments.js} +0 -0
- /package/dist-node/src/{fork-workspace.js → git/fork-workspace.js} +0 -0
- /package/dist-node/src/{merge.js → git/merge.js} +0 -0
- /package/dist-node/src/{pr.js → git/pr.js} +0 -0
- /package/dist-node/src/{nosleep.js → host/nosleep.js} +0 -0
- /package/dist-node/src/{relay-processes.js → host/relay-processes.js} +0 -0
- /package/dist-node/src/{ui-lease-watchdog.js → host/ui-lease-watchdog.js} +0 -0
- /package/dist-node/src/{wifi.js → host/wifi.js} +0 -0
- /package/dist-node/src/{orchestration-schema.generated.js → orchestration/persistence/schema.generated.js} +0 -0
- /package/dist-node/src/{workflow-effect-runner.js → orchestration/workflow/effect-runner.js} +0 -0
- /package/dist-node/src/{workflow-http.js → orchestration/workflow/http.js} +0 -0
- /package/dist-node/src/{workflow-machine.js → orchestration/workflow/machine.js} +0 -0
- /package/dist-node/src/{session-poller.js → reads/session-poller.js} +0 -0
- /package/dist-node/src/{context-breakdown.js → transcript/context-breakdown.js} +0 -0
- /package/dist-node/src/{chat-cursor.js → transcript/cursor.js} +0 -0
- /package/dist-node/src/{plan-usage.js → usage/plan-usage.js} +0 -0
- /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
|