conductor-remote 1.116.0 → 1.118.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/README.md +10 -8
  2. package/dist/assets/{PierrePatch-BPp5Cf-p.js → PierrePatch-D4YbKD8Q.js} +1 -1
  3. package/dist/assets/index-BRzsSW8g.css +1 -0
  4. package/dist/assets/index-BaS6qXUW.js +65 -0
  5. package/dist/index.html +2 -2
  6. package/dist/push-sw.js +2 -2
  7. package/dist/sw.js +1 -1
  8. package/dist-node/scripts/nosleep-setup.js +2 -2
  9. package/dist-node/scripts/nosleep.js +3 -3
  10. package/dist-node/scripts/service/commands.js +32 -0
  11. package/dist-node/scripts/service/configuration.js +205 -0
  12. package/dist-node/scripts/service/environment.js +59 -0
  13. package/dist-node/scripts/service/flags.js +45 -0
  14. package/dist-node/scripts/service/installation.js +91 -0
  15. package/dist-node/scripts/service/launch-agent.js +134 -0
  16. package/dist-node/scripts/service/network.js +244 -0
  17. package/dist-node/scripts/service/presentation.js +101 -0
  18. package/dist-node/scripts/service/voice.js +113 -0
  19. package/dist-node/scripts/service.js +5 -980
  20. package/dist-node/src/{agent-config.js → agents/agent-config.js} +1 -1
  21. package/dist-node/src/{model-cache.js → agents/model-cache.js} +1 -1
  22. package/dist-node/src/{roles.js → agents/roles.js} +3 -3
  23. package/dist-node/src/db.js +1 -1
  24. package/dist-node/src/{parked.js → delivery/parked.js} +1 -1
  25. package/dist-node/src/{dev-server.js → dev-server/controller.js} +8 -291
  26. package/dist-node/src/dev-server/ports.js +63 -0
  27. package/dist-node/src/dev-server/processes.js +73 -0
  28. package/dist-node/src/dev-server/proxy.js +158 -0
  29. package/dist-node/src/{run-activity.js → dev-server/run-activity.js} +2 -2
  30. package/dist-node/src/dev-server/types.js +1 -0
  31. package/dist-node/src/{file-preview.js → files/file-preview.js} +1 -1
  32. package/dist-node/src/{change-stats.js → git/change-stats.js} +1 -1
  33. package/dist-node/src/{git.js → git/diff.js} +1 -1
  34. package/dist-node/src/{autoupdate.js → host/autoupdate.js} +1 -1
  35. package/dist-node/src/{funnel-watchdog.js → host/funnel-watchdog.js} +4 -4
  36. package/dist-node/src/{logbuf.js → host/logbuf.js} +1 -1
  37. package/dist-node/src/{nosleep-helper.js → host/nosleep-helper.js} +1 -1
  38. package/dist-node/src/{tailscale.js → host/tailscale.js} +1 -1
  39. package/dist-node/src/http/router-types.js +2 -0
  40. package/dist-node/src/http/router.js +78 -0
  41. package/dist-node/src/http/routes/create-workspace.js +90 -0
  42. package/dist-node/src/http/routes/files.js +53 -0
  43. package/dist-node/src/http/routes/prompts.js +363 -0
  44. package/dist-node/src/http/routes/sessions.js +279 -0
  45. package/dist-node/src/http/routes/state.js +186 -0
  46. package/dist-node/src/http/routes/system.js +220 -0
  47. package/dist-node/src/http/routes/voice.js +117 -0
  48. package/dist-node/src/http/routes/workflows.js +248 -0
  49. package/dist-node/src/http/routes/workspaces.js +267 -0
  50. package/dist-node/src/http/services/base.js +252 -0
  51. package/dist-node/src/http/services/delegations.js +301 -0
  52. package/dist-node/src/http/services/delivery.js +433 -0
  53. package/dist-node/src/http/services/files.js +199 -0
  54. package/dist-node/src/http/services/mcp.js +94 -0
  55. package/dist-node/src/http/services/responses.js +120 -0
  56. package/dist-node/src/http/services/voice.js +147 -0
  57. package/dist-node/src/http/services/workflow-probes.js +215 -0
  58. package/dist-node/src/http/services/workflow-state.js +195 -0
  59. package/dist-node/src/http/services/workflow.js +366 -0
  60. package/dist-node/src/http/services.js +35 -0
  61. package/dist-node/src/mcp/arguments.js +14 -0
  62. package/dist-node/src/mcp/dispatcher.js +56 -0
  63. package/dist-node/src/mcp/formatters.js +110 -0
  64. package/dist-node/src/mcp/protocol.js +7 -0
  65. package/dist-node/src/mcp/registry.js +66 -0
  66. package/dist-node/src/mcp/tools/agent-options.js +122 -0
  67. package/dist-node/src/mcp/tools/chat-actions.js +194 -0
  68. package/dist-node/src/mcp/tools/chats.js +194 -0
  69. package/dist-node/src/mcp/tools/delegation.js +135 -0
  70. package/dist-node/src/mcp/tools/system.js +135 -0
  71. package/dist-node/src/mcp/tools/voice.js +136 -0
  72. package/dist-node/src/mcp/tools/workspaces.js +259 -0
  73. package/dist-node/src/mcp/types.js +1 -0
  74. package/dist-node/src/mcp.js +6 -4
  75. package/dist-node/src/{notify.js → notifications/notify.js} +5 -5
  76. package/dist-node/src/{webpush.js → notifications/webpush.js} +1 -1
  77. package/dist-node/src/orchestration/delegation/codec.js +201 -0
  78. package/dist-node/src/{delegation-intake.js → orchestration/delegation/intake.js} +2 -2
  79. package/dist-node/src/{delegation-prompt.js → orchestration/delegation/prompt.js} +1 -1
  80. package/dist-node/src/orchestration/delegation/queue.js +255 -0
  81. package/dist-node/src/orchestration/delegation/store.js +111 -0
  82. package/dist-node/src/orchestration/delegation/types.js +2 -0
  83. package/dist-node/src/orchestration/persistence/capabilities.js +109 -0
  84. package/dist-node/src/orchestration/persistence/codecs.js +148 -0
  85. package/dist-node/src/orchestration/persistence/connection.js +205 -0
  86. package/dist-node/src/orchestration/persistence/db.js +101 -0
  87. package/dist-node/src/orchestration/persistence/effect-recovery.js +227 -0
  88. package/dist-node/src/orchestration/persistence/effect-transitions.js +157 -0
  89. package/dist-node/src/orchestration/persistence/effects.js +109 -0
  90. package/dist-node/src/orchestration/persistence/errors.js +39 -0
  91. package/dist-node/src/orchestration/persistence/events.js +29 -0
  92. package/dist-node/src/orchestration/persistence/idempotency.js +82 -0
  93. package/dist-node/src/orchestration/persistence/job-attempts.js +114 -0
  94. package/dist-node/src/orchestration/persistence/jobs.js +159 -0
  95. package/dist-node/src/orchestration/persistence/projections.js +190 -0
  96. package/dist-node/src/orchestration/persistence/quarantine.js +46 -0
  97. package/dist-node/src/orchestration/persistence/records.js +115 -0
  98. package/dist-node/src/orchestration/persistence/relays.js +78 -0
  99. package/dist-node/src/orchestration/persistence/runs.js +156 -0
  100. package/dist-node/src/{orchestration-schema.js → orchestration/persistence/schema.js} +1 -1
  101. package/dist-node/src/orchestration/persistence/types.js +1 -0
  102. package/dist-node/src/orchestration/persistence/ui-lease.js +197 -0
  103. package/dist-node/src/orchestration/persistence/values.js +114 -0
  104. package/dist-node/src/orchestration/workflow/commands.js +309 -0
  105. package/dist-node/src/orchestration/workflow/coordinator.js +28 -0
  106. package/dist-node/src/orchestration/workflow/effect-recovery.js +125 -0
  107. package/dist-node/src/orchestration/workflow/effects.js +307 -0
  108. package/dist-node/src/orchestration/workflow/errors.js +13 -0
  109. package/dist-node/src/orchestration/workflow/helpers.js +154 -0
  110. package/dist-node/src/orchestration/workflow/job-dispatch.js +312 -0
  111. package/dist-node/src/orchestration/workflow/job-results.js +280 -0
  112. package/dist-node/src/{workflow.js → orchestration/workflow/prompts.js} +2 -2
  113. package/dist-node/src/orchestration/workflow/root.js +293 -0
  114. package/dist-node/src/orchestration/workflow/start.js +99 -0
  115. package/dist-node/src/orchestration/workflow/state.js +105 -0
  116. package/dist-node/src/orchestration/workflow/types.js +1 -0
  117. package/dist-node/src/orchestration/workflow/wake.js +164 -0
  118. package/dist-node/src/prefs.js +1 -1
  119. package/dist-node/src/{background-tasks.js → reads/background-tasks.js} +1 -1
  120. package/dist-node/src/reads/messages.js +362 -0
  121. package/dist-node/src/reads/repository.js +87 -0
  122. package/dist-node/src/reads/sessions.js +165 -0
  123. package/dist-node/src/reads/types.js +1 -0
  124. package/dist-node/src/reads/workspaces.js +230 -0
  125. package/dist-node/src/reads/worktrees.js +45 -0
  126. package/dist-node/src/routes.js +5 -5
  127. package/dist-node/src/{search.js → search/coordinator.js} +4 -4
  128. package/dist-node/src/{search-worker.js → search/worker.js} +3 -3
  129. package/dist-node/src/server.js +14 -3930
  130. package/dist-node/src/settings.js +1 -1
  131. package/dist-node/src/shared.js +130 -9
  132. package/dist-node/src/{transcript.js → transcript/parser.js} +4 -124
  133. package/dist-node/src/{tool-usage-worker.js → usage/tool-usage-worker.js} +1 -1
  134. package/dist-node/src/{tool-usage.js → usage/tool-usage.js} +1 -1
  135. package/dist-node/src/voice/brief.js +22 -50
  136. package/dist-node/src/voice/context.js +1 -1
  137. package/dist-node/src/voice/dates.js +38 -0
  138. package/dist-node/src/voice/history.js +14 -5
  139. package/dist-node/src/voice/prompt.js +10 -9
  140. package/dist-node/src/voice/recall.js +115 -0
  141. package/dist-node/src/{speech.js → voice/speech.js} +1 -1
  142. package/dist-node/src/voice/tools.js +92 -3
  143. package/dist-node/src/voice/webrtc.js +1 -1
  144. package/dist-node/src/wire.js +2 -2
  145. package/dist-node/src/writes/actuator.js +124 -0
  146. package/dist-node/src/writes/agent-options.js +151 -0
  147. package/dist-node/src/writes/applescript/agent-options.applescript +334 -0
  148. package/dist-node/src/writes/applescript/chat-targeting.applescript +149 -0
  149. package/dist-node/src/writes/applescript/composer.applescript +195 -0
  150. package/dist-node/src/writes/applescript/hotspot.applescript +155 -0
  151. package/dist-node/src/writes/applescript/lifecycle.applescript +266 -0
  152. package/dist-node/src/writes/applescript/run-tasks.applescript +208 -0
  153. package/dist-node/src/writes/applescript/source.js +52 -0
  154. package/dist-node/src/writes/applescript/window.applescript +436 -0
  155. package/dist-node/src/writes/applescript/workspace-status.applescript +177 -0
  156. package/dist-node/src/writes/applescript/workspace-targeting.applescript +359 -0
  157. package/dist-node/src/writes/chats.js +160 -0
  158. package/dist-node/src/writes/guards.js +77 -0
  159. package/dist-node/src/writes/runner.js +67 -0
  160. package/dist-node/src/{sidecar.js → writes/sidecar.js} +1 -1
  161. package/dist-node/src/writes/system.js +81 -0
  162. package/dist-node/src/writes/targeting.js +121 -0
  163. package/dist-node/src/writes/types.js +1 -0
  164. package/dist-node/src/writes/ui-lock.js +229 -0
  165. package/dist-node/src/writes/workspaces.js +233 -0
  166. package/docs/voice-setup.md +34 -12
  167. package/package.json +2 -2
  168. package/dist/assets/index-DNLdIfZ2.css +0 -1
  169. package/dist/assets/index-QJR7tn2I.js +0 -61
  170. package/dist-node/src/conductor.applescript +0 -2279
  171. package/dist-node/src/delegations.js +0 -557
  172. package/dist-node/src/mcp-tools.js +0 -1319
  173. package/dist-node/src/orchestration-db.js +0 -2186
  174. package/dist-node/src/reads.js +0 -787
  175. package/dist-node/src/workflow-coordinator.js +0 -2119
  176. package/dist-node/src/writes.js +0 -1223
  177. /package/dist-node/src/{conductor-settings.js → agents/conductor-settings.js} +0 -0
  178. /package/dist-node/src/{firstprompt.js → delivery/firstprompt.js} +0 -0
  179. /package/dist-node/src/{sendonce.js → delivery/sendonce.js} +0 -0
  180. /package/dist-node/src/{preview-urls.js → dev-server/preview-urls.js} +0 -0
  181. /package/dist-node/src/{run-configs.js → dev-server/run-configs.js} +0 -0
  182. /package/dist-node/src/{attachments.js → files/attachments.js} +0 -0
  183. /package/dist-node/src/{icons.js → files/icons.js} +0 -0
  184. /package/dist-node/src/{staged-attachments.js → files/staged-attachments.js} +0 -0
  185. /package/dist-node/src/{fork-workspace.js → git/fork-workspace.js} +0 -0
  186. /package/dist-node/src/{merge.js → git/merge.js} +0 -0
  187. /package/dist-node/src/{pr.js → git/pr.js} +0 -0
  188. /package/dist-node/src/{nosleep.js → host/nosleep.js} +0 -0
  189. /package/dist-node/src/{relay-processes.js → host/relay-processes.js} +0 -0
  190. /package/dist-node/src/{ui-lease-watchdog.js → host/ui-lease-watchdog.js} +0 -0
  191. /package/dist-node/src/{wifi.js → host/wifi.js} +0 -0
  192. /package/dist-node/src/{orchestration-schema.generated.js → orchestration/persistence/schema.generated.js} +0 -0
  193. /package/dist-node/src/{workflow-effect-runner.js → orchestration/workflow/effect-runner.js} +0 -0
  194. /package/dist-node/src/{workflow-http.js → orchestration/workflow/http.js} +0 -0
  195. /package/dist-node/src/{workflow-machine.js → orchestration/workflow/machine.js} +0 -0
  196. /package/dist-node/src/{session-poller.js → reads/session-poller.js} +0 -0
  197. /package/dist-node/src/{context-breakdown.js → transcript/context-breakdown.js} +0 -0
  198. /package/dist-node/src/{chat-cursor.js → transcript/cursor.js} +0 -0
  199. /package/dist-node/src/{plan-usage.js → usage/plan-usage.js} +0 -0
  200. /package/dist-node/src/{tool-usage-service.js → usage/tool-usage-service.js} +0 -0
@@ -0,0 +1,436 @@
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
@@ -0,0 +1,177 @@
1
+
2
+ on axRole(el)
3
+ tell application "System Events" to tell process "Conductor"
4
+ try
5
+ return role of el
6
+ on error
7
+ return ""
8
+ end try
9
+ end tell
10
+ end axRole
11
+
12
+ on axKids(el)
13
+ tell application "System Events" to tell process "Conductor"
14
+ try
15
+ return UI elements of el
16
+ on error
17
+ return {}
18
+ end try
19
+ end tell
20
+ end axKids
21
+
22
+ on menusUnder(root, maxDepth)
23
+ -- Every AXMenu at or below root, breadth-first and bounded. The status
24
+ -- submenu opens *nested inside* the row menu rather than beside it, so a
25
+ -- search that stops at the first hit (the way setModel scans the web area's
26
+ -- direct children) never sees it.
27
+ set level to {root}
28
+ set found to {}
29
+ set depth to 0
30
+ repeat while (count of level) > 0 and depth < maxDepth
31
+ set nextLevel to {}
32
+ repeat with entry in level
33
+ set node to contents of entry
34
+ if (my axRole(node)) is "AXMenu" then set end of found to node
35
+ set nextLevel to nextLevel & (my axKids(node))
36
+ end repeat
37
+ set level to nextLevel
38
+ set depth to depth + 1
39
+ end repeat
40
+ return found
41
+ end menusUnder
42
+
43
+ on axName(el)
44
+ -- Guarded: a menu re-renders under us, and reading the name of an element that
45
+ -- has since gone throws an object-specifier error from wherever we happened to be.
46
+ tell application "System Events" to tell process "Conductor"
47
+ try
48
+ set n to name of el
49
+ if n is missing value then return ""
50
+ return n as text
51
+ on error
52
+ return ""
53
+ end try
54
+ end tell
55
+ end axName
56
+
57
+ on menuItemNamed(m, wanted)
58
+ repeat with mi in (my axKids(m))
59
+ set node to contents of mi
60
+ if (my axRole(node)) is "AXMenuItem" and (my axName(node)) is wanted then return node
61
+ end repeat
62
+ return missing value
63
+ end menuItemNamed
64
+
65
+ on waitForMenuWith(root, maxDepth, itemName, attempts)
66
+ -- Identify the menu by an item it *contains*, never as "the first AXMenu on
67
+ -- screen": a model picker left open, or a menu from the run before, is also an
68
+ -- AXMenu, and picking it produced "no Set status item" on a menu that had never
69
+ -- been the workspace's. A menu is drawn by the webview, not by us, so how long
70
+ -- it takes varies with what Conductor is busy doing — poll rather than guess
71
+ -- one delay; the common case costs a single sweep.
72
+ repeat with i from 1 to attempts
73
+ repeat with entry in my menusUnder(root, maxDepth)
74
+ set node to contents of entry
75
+ if (my menuItemNamed(node, itemName)) is not missing value then return node
76
+ end repeat
77
+ delay 0.5
78
+ end repeat
79
+ return missing value
80
+ end waitForMenuWith
81
+
82
+ on dismissMenus()
83
+ -- Two escapes: one for the submenu, one for the row menu. Leaving either open
84
+ -- would swallow the next run's keystrokes.
85
+ tell application "System Events"
86
+ key code 53
87
+ delay 0.25
88
+ key code 53
89
+ end tell
90
+ end dismissMenus
91
+
92
+ on pressStatusMenu(rowEl, wanted)
93
+ -- Scroll it into view first. A row that exists in the AX tree but sits outside
94
+ -- the sidebar's visible strip accepts AXShowMenu and draws nothing — which is
95
+ -- exactly what happens right after a status change moves it to another group.
96
+ tell application "System Events" to tell process "Conductor"
97
+ try
98
+ perform action "AXScrollToVisible" of rowEl
99
+ end try
100
+ end tell
101
+ delay 0.4
102
+ -- Clear anything already open (a picker, or a menu a previous run left behind)
103
+ -- so the sweep below can only match the one this right-click draws.
104
+ tell application "System Events" to key code 53
105
+ delay 0.3
106
+ tell application "System Events" to tell process "Conductor"
107
+ perform action "AXShowMenu" of rowEl
108
+ end tell
109
+ -- Depth 4, not the whole tree: the row menu is a portal near the top of the web
110
+ -- area, and the *transcript* hangs off that same root — a deep sweep walks every
111
+ -- message bubble and costs more than the rest of this write put together (it
112
+ -- grows with the conversation, so it degrades as you use the app).
113
+ set rowMenu to my waitForMenuWith(my webArea(), 4, "Set status", 6)
114
+ if rowMenu is missing value then
115
+ my dismissMenus()
116
+ error "the workspace's menu didn't open — or it no longer offers Set status"
117
+ end if
118
+ set statusItem to my menuItemNamed(rowMenu, "Set status")
119
+ tell application "System Events" to tell process "Conductor"
120
+ perform action "AXPress" of statusItem
121
+ end tell
122
+ -- The submenu is an AXMenu nested a few levels *inside* the row menu (Conductor
123
+ -- expands it in place rather than opening a sibling), so search from that menu —
124
+ -- searching the web area again would re-walk the world. Two things make this a
125
+ -- poll rather than a read: the row-menu handle goes stale across the expansion
126
+ -- (it reports no children instead of erroring), so it is re-found each pass; and
127
+ -- the submenu's *items* arrive a beat after the submenu itself, so the thing we
128
+ -- wait for is the wanted status, not the container that will hold it.
129
+ set subMenu to missing value
130
+ repeat with attempt from 1 to 8
131
+ set liveMenu to my waitForMenuWith(my webArea(), 4, "Set status", 1)
132
+ if liveMenu is not missing value then
133
+ set subMenu to my waitForMenuWith(liveMenu, 5, wanted, 1)
134
+ end if
135
+ if subMenu is not missing value then exit repeat
136
+ delay 0.4
137
+ end repeat
138
+ if subMenu is missing value then
139
+ my dismissMenus()
140
+ error "Conductor never offered a status called " & wanted
141
+ end if
142
+ set choice to my menuItemNamed(subMenu, wanted)
143
+ tell application "System Events" to tell process "Conductor"
144
+ perform action "AXPress" of choice
145
+ end tell
146
+ delay 0.6
147
+ end pressStatusMenu
148
+
149
+ on setWorkspaceStatus()
150
+ -- Conductor has no menu-bar or palette command for this, so the only lever is
151
+ -- the sidebar row's own context menu (Mark as unread / Pin / Set status /
152
+ -- Rename / Copy link / Archive). Right-clicking the row needs no focus change,
153
+ -- so unlike a send this never disturbs which workspace is on screen.
154
+ set wanted to system attribute "RELAY_SET_STATUS"
155
+ if wanted is "" then error "no status requested"
156
+ set theRow to my findSidebarRow()
157
+ set opened to {}
158
+ if theRow is missing value then
159
+ -- A folded section renders no rows, so the row may simply not be in the tree
160
+ -- yet. There is no palette command to fall back to, so open the sections
161
+ -- ourselves and look again — then fold them back, since the one thing this
162
+ -- write promises is that the sidebar is where you left it.
163
+ set opened to my expandSections()
164
+ if (count of opened) > 0 then set theRow to my findSidebarRow()
165
+ end if
166
+ if theRow is missing value then
167
+ my restoreSections(opened)
168
+ error "couldn't find this workspace in the sidebar"
169
+ end if
170
+ try
171
+ my pressStatusMenu(theRow, wanted)
172
+ on error errMsg
173
+ my restoreSections(opened)
174
+ error errMsg
175
+ end try
176
+ my restoreSections(opened)
177
+ end setWorkspaceStatus