conductor-remote 1.117.0 → 1.118.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/README.md +10 -8
  2. package/dist/assets/{PierrePatch-D3_YBIAr.js → PierrePatch-D4YbKD8Q.js} +1 -1
  3. package/dist/assets/index-BRzsSW8g.css +1 -0
  4. package/dist/assets/index-BaS6qXUW.js +65 -0
  5. package/dist/index.html +2 -2
  6. package/dist/push-sw.js +2 -2
  7. package/dist/sw.js +1 -1
  8. package/dist-node/scripts/nosleep-setup.js +2 -2
  9. package/dist-node/scripts/nosleep.js +3 -3
  10. package/dist-node/scripts/service/commands.js +32 -0
  11. package/dist-node/scripts/service/configuration.js +205 -0
  12. package/dist-node/scripts/service/environment.js +59 -0
  13. package/dist-node/scripts/service/flags.js +45 -0
  14. package/dist-node/scripts/service/installation.js +91 -0
  15. package/dist-node/scripts/service/launch-agent.js +134 -0
  16. package/dist-node/scripts/service/network.js +244 -0
  17. package/dist-node/scripts/service/presentation.js +101 -0
  18. package/dist-node/scripts/service/voice.js +113 -0
  19. package/dist-node/scripts/service.js +5 -980
  20. package/dist-node/src/{agent-config.js → agents/agent-config.js} +1 -1
  21. package/dist-node/src/{model-cache.js → agents/model-cache.js} +1 -1
  22. package/dist-node/src/{roles.js → agents/roles.js} +3 -3
  23. package/dist-node/src/db.js +1 -1
  24. package/dist-node/src/{parked.js → delivery/parked.js} +1 -1
  25. package/dist-node/src/{dev-server.js → dev-server/controller.js} +8 -291
  26. package/dist-node/src/dev-server/ports.js +63 -0
  27. package/dist-node/src/dev-server/processes.js +73 -0
  28. package/dist-node/src/dev-server/proxy.js +158 -0
  29. package/dist-node/src/{run-activity.js → dev-server/run-activity.js} +2 -2
  30. package/dist-node/src/dev-server/types.js +1 -0
  31. package/dist-node/src/{file-preview.js → files/file-preview.js} +1 -1
  32. package/dist-node/src/{change-stats.js → git/change-stats.js} +1 -1
  33. package/dist-node/src/{git.js → git/diff.js} +1 -1
  34. package/dist-node/src/{autoupdate.js → host/autoupdate.js} +1 -1
  35. package/dist-node/src/{funnel-watchdog.js → host/funnel-watchdog.js} +4 -4
  36. package/dist-node/src/{logbuf.js → host/logbuf.js} +1 -1
  37. package/dist-node/src/{nosleep-helper.js → host/nosleep-helper.js} +1 -1
  38. package/dist-node/src/{tailscale.js → host/tailscale.js} +1 -1
  39. package/dist-node/src/http/router-types.js +2 -0
  40. package/dist-node/src/http/router.js +78 -0
  41. package/dist-node/src/http/routes/create-workspace.js +90 -0
  42. package/dist-node/src/http/routes/files.js +53 -0
  43. package/dist-node/src/http/routes/prompts.js +363 -0
  44. package/dist-node/src/http/routes/sessions.js +279 -0
  45. package/dist-node/src/http/routes/state.js +186 -0
  46. package/dist-node/src/http/routes/system.js +220 -0
  47. package/dist-node/src/http/routes/voice.js +117 -0
  48. package/dist-node/src/http/routes/workflows.js +248 -0
  49. package/dist-node/src/http/routes/workspaces.js +267 -0
  50. package/dist-node/src/http/services/base.js +252 -0
  51. package/dist-node/src/http/services/delegations.js +301 -0
  52. package/dist-node/src/http/services/delivery.js +433 -0
  53. package/dist-node/src/http/services/files.js +199 -0
  54. package/dist-node/src/http/services/mcp.js +94 -0
  55. package/dist-node/src/http/services/responses.js +120 -0
  56. package/dist-node/src/http/services/voice.js +147 -0
  57. package/dist-node/src/http/services/workflow-probes.js +215 -0
  58. package/dist-node/src/http/services/workflow-state.js +195 -0
  59. package/dist-node/src/http/services/workflow.js +366 -0
  60. package/dist-node/src/http/services.js +35 -0
  61. package/dist-node/src/mcp/arguments.js +14 -0
  62. package/dist-node/src/mcp/dispatcher.js +56 -0
  63. package/dist-node/src/mcp/formatters.js +110 -0
  64. package/dist-node/src/mcp/protocol.js +7 -0
  65. package/dist-node/src/mcp/registry.js +66 -0
  66. package/dist-node/src/mcp/tools/agent-options.js +122 -0
  67. package/dist-node/src/mcp/tools/chat-actions.js +194 -0
  68. package/dist-node/src/mcp/tools/chats.js +194 -0
  69. package/dist-node/src/mcp/tools/delegation.js +135 -0
  70. package/dist-node/src/mcp/tools/system.js +135 -0
  71. package/dist-node/src/mcp/tools/voice.js +136 -0
  72. package/dist-node/src/mcp/tools/workspaces.js +259 -0
  73. package/dist-node/src/mcp/types.js +1 -0
  74. package/dist-node/src/mcp.js +6 -4
  75. package/dist-node/src/{notify.js → notifications/notify.js} +5 -5
  76. package/dist-node/src/{webpush.js → notifications/webpush.js} +1 -1
  77. package/dist-node/src/orchestration/delegation/codec.js +201 -0
  78. package/dist-node/src/{delegation-intake.js → orchestration/delegation/intake.js} +2 -2
  79. package/dist-node/src/{delegation-prompt.js → orchestration/delegation/prompt.js} +1 -1
  80. package/dist-node/src/orchestration/delegation/queue.js +255 -0
  81. package/dist-node/src/orchestration/delegation/store.js +111 -0
  82. package/dist-node/src/orchestration/delegation/types.js +2 -0
  83. package/dist-node/src/orchestration/persistence/capabilities.js +109 -0
  84. package/dist-node/src/orchestration/persistence/codecs.js +148 -0
  85. package/dist-node/src/orchestration/persistence/connection.js +205 -0
  86. package/dist-node/src/orchestration/persistence/db.js +101 -0
  87. package/dist-node/src/orchestration/persistence/effect-recovery.js +227 -0
  88. package/dist-node/src/orchestration/persistence/effect-transitions.js +157 -0
  89. package/dist-node/src/orchestration/persistence/effects.js +109 -0
  90. package/dist-node/src/orchestration/persistence/errors.js +39 -0
  91. package/dist-node/src/orchestration/persistence/events.js +29 -0
  92. package/dist-node/src/orchestration/persistence/idempotency.js +82 -0
  93. package/dist-node/src/orchestration/persistence/job-attempts.js +114 -0
  94. package/dist-node/src/orchestration/persistence/jobs.js +159 -0
  95. package/dist-node/src/orchestration/persistence/projections.js +190 -0
  96. package/dist-node/src/orchestration/persistence/quarantine.js +46 -0
  97. package/dist-node/src/orchestration/persistence/records.js +115 -0
  98. package/dist-node/src/orchestration/persistence/relays.js +78 -0
  99. package/dist-node/src/orchestration/persistence/runs.js +156 -0
  100. package/dist-node/src/{orchestration-schema.js → orchestration/persistence/schema.js} +1 -1
  101. package/dist-node/src/orchestration/persistence/types.js +1 -0
  102. package/dist-node/src/orchestration/persistence/ui-lease.js +197 -0
  103. package/dist-node/src/orchestration/persistence/values.js +114 -0
  104. package/dist-node/src/orchestration/workflow/commands.js +309 -0
  105. package/dist-node/src/orchestration/workflow/coordinator.js +28 -0
  106. package/dist-node/src/orchestration/workflow/effect-recovery.js +125 -0
  107. package/dist-node/src/orchestration/workflow/effects.js +307 -0
  108. package/dist-node/src/orchestration/workflow/errors.js +13 -0
  109. package/dist-node/src/orchestration/workflow/helpers.js +154 -0
  110. package/dist-node/src/orchestration/workflow/job-dispatch.js +312 -0
  111. package/dist-node/src/orchestration/workflow/job-results.js +280 -0
  112. package/dist-node/src/{workflow.js → orchestration/workflow/prompts.js} +2 -2
  113. package/dist-node/src/orchestration/workflow/root.js +293 -0
  114. package/dist-node/src/orchestration/workflow/start.js +99 -0
  115. package/dist-node/src/orchestration/workflow/state.js +105 -0
  116. package/dist-node/src/orchestration/workflow/types.js +1 -0
  117. package/dist-node/src/orchestration/workflow/wake.js +164 -0
  118. package/dist-node/src/prefs.js +1 -1
  119. package/dist-node/src/{background-tasks.js → reads/background-tasks.js} +1 -1
  120. package/dist-node/src/reads/messages.js +362 -0
  121. package/dist-node/src/reads/repository.js +87 -0
  122. package/dist-node/src/reads/sessions.js +165 -0
  123. package/dist-node/src/reads/types.js +1 -0
  124. package/dist-node/src/reads/workspaces.js +230 -0
  125. package/dist-node/src/reads/worktrees.js +45 -0
  126. package/dist-node/src/routes.js +5 -5
  127. package/dist-node/src/{search.js → search/coordinator.js} +4 -4
  128. package/dist-node/src/{search-worker.js → search/worker.js} +3 -3
  129. package/dist-node/src/server.js +14 -3930
  130. package/dist-node/src/settings.js +1 -1
  131. package/dist-node/src/shared.js +10 -10
  132. package/dist-node/src/{transcript.js → transcript/parser.js} +4 -4
  133. package/dist-node/src/{tool-usage-worker.js → usage/tool-usage-worker.js} +1 -1
  134. package/dist-node/src/{tool-usage.js → usage/tool-usage.js} +1 -1
  135. package/dist-node/src/voice/brief.js +22 -50
  136. package/dist-node/src/voice/context.js +1 -1
  137. package/dist-node/src/voice/dates.js +38 -0
  138. package/dist-node/src/voice/history.js +14 -5
  139. package/dist-node/src/voice/prompt.js +10 -9
  140. package/dist-node/src/voice/recall.js +115 -0
  141. package/dist-node/src/{speech.js → voice/speech.js} +1 -1
  142. package/dist-node/src/voice/tools.js +92 -3
  143. package/dist-node/src/voice/webrtc.js +1 -1
  144. package/dist-node/src/wire.js +2 -2
  145. package/dist-node/src/writes/actuator.js +124 -0
  146. package/dist-node/src/writes/agent-options.js +151 -0
  147. package/dist-node/src/writes/applescript/agent-options.applescript +334 -0
  148. package/dist-node/src/writes/applescript/chat-targeting.applescript +149 -0
  149. package/dist-node/src/writes/applescript/composer.applescript +195 -0
  150. package/dist-node/src/writes/applescript/hotspot.applescript +155 -0
  151. package/dist-node/src/writes/applescript/lifecycle.applescript +266 -0
  152. package/dist-node/src/writes/applescript/run-tasks.applescript +208 -0
  153. package/dist-node/src/writes/applescript/source.js +52 -0
  154. package/dist-node/src/writes/applescript/window.applescript +436 -0
  155. package/dist-node/src/writes/applescript/workspace-status.applescript +177 -0
  156. package/dist-node/src/writes/applescript/workspace-targeting.applescript +359 -0
  157. package/dist-node/src/writes/chats.js +160 -0
  158. package/dist-node/src/writes/guards.js +77 -0
  159. package/dist-node/src/writes/runner.js +67 -0
  160. package/dist-node/src/{sidecar.js → writes/sidecar.js} +1 -1
  161. package/dist-node/src/writes/system.js +81 -0
  162. package/dist-node/src/writes/targeting.js +121 -0
  163. package/dist-node/src/writes/types.js +1 -0
  164. package/dist-node/src/writes/ui-lock.js +229 -0
  165. package/dist-node/src/writes/workspaces.js +233 -0
  166. package/docs/voice-setup.md +34 -12
  167. package/package.json +2 -2
  168. package/dist/assets/index-ByTwVInL.css +0 -1
  169. package/dist/assets/index-LAm9X7wv.js +0 -65
  170. package/dist-node/src/conductor.applescript +0 -2279
  171. package/dist-node/src/delegations.js +0 -557
  172. package/dist-node/src/mcp-tools.js +0 -1319
  173. package/dist-node/src/orchestration-db.js +0 -2186
  174. package/dist-node/src/reads.js +0 -787
  175. package/dist-node/src/workflow-coordinator.js +0 -2119
  176. package/dist-node/src/writes.js +0 -1223
  177. /package/dist-node/src/{conductor-settings.js → agents/conductor-settings.js} +0 -0
  178. /package/dist-node/src/{firstprompt.js → delivery/firstprompt.js} +0 -0
  179. /package/dist-node/src/{sendonce.js → delivery/sendonce.js} +0 -0
  180. /package/dist-node/src/{preview-urls.js → dev-server/preview-urls.js} +0 -0
  181. /package/dist-node/src/{run-configs.js → dev-server/run-configs.js} +0 -0
  182. /package/dist-node/src/{attachments.js → files/attachments.js} +0 -0
  183. /package/dist-node/src/{icons.js → files/icons.js} +0 -0
  184. /package/dist-node/src/{staged-attachments.js → files/staged-attachments.js} +0 -0
  185. /package/dist-node/src/{fork-workspace.js → git/fork-workspace.js} +0 -0
  186. /package/dist-node/src/{merge.js → git/merge.js} +0 -0
  187. /package/dist-node/src/{pr.js → git/pr.js} +0 -0
  188. /package/dist-node/src/{nosleep.js → host/nosleep.js} +0 -0
  189. /package/dist-node/src/{relay-processes.js → host/relay-processes.js} +0 -0
  190. /package/dist-node/src/{ui-lease-watchdog.js → host/ui-lease-watchdog.js} +0 -0
  191. /package/dist-node/src/{wifi.js → host/wifi.js} +0 -0
  192. /package/dist-node/src/{orchestration-schema.generated.js → orchestration/persistence/schema.generated.js} +0 -0
  193. /package/dist-node/src/{workflow-effect-runner.js → orchestration/workflow/effect-runner.js} +0 -0
  194. /package/dist-node/src/{workflow-http.js → orchestration/workflow/http.js} +0 -0
  195. /package/dist-node/src/{workflow-machine.js → orchestration/workflow/machine.js} +0 -0
  196. /package/dist-node/src/{session-poller.js → reads/session-poller.js} +0 -0
  197. /package/dist-node/src/{context-breakdown.js → transcript/context-breakdown.js} +0 -0
  198. /package/dist-node/src/{chat-cursor.js → transcript/cursor.js} +0 -0
  199. /package/dist-node/src/{plan-usage.js → usage/plan-usage.js} +0 -0
  200. /package/dist-node/src/{tool-usage-service.js → usage/tool-usage-service.js} +0 -0
@@ -1,1223 +0,0 @@
1
- import { AsyncLocalStorage } from 'node:async_hooks';
2
- import { execFile } from 'node:child_process';
3
- import { readFileSync } from 'node:fs';
4
- import path from 'node:path';
5
- import { promisify } from 'node:util';
6
- import { isLockedError, MAC_LOCKED, modelPickerLabel } from "./shared.js";
7
- import { sidecarAvailable, sidecarSendUserMessage } from "./sidecar.js";
8
- import { runGatedWorkflowCommand, WorkflowGatedCommandError } from "./workflow-effect-runner.js";
9
- const execDirect = promisify(execFile);
10
- /** Waiting runs past this are refused rather than queued. */
11
- const MAX_UI_QUEUE = 4;
12
- const UI_BUSY = "Conductor's UI is busy";
13
- const uiPriorityScope = new AsyncLocalStorage();
14
- const uiDispatchScope = new AsyncLocalStorage();
15
- const uiCommandGateScope = new AsyncLocalStorage();
16
- const uiLeaseExecutionScope = new AsyncLocalStorage();
17
- let sharedUiLeaseProvider = null;
18
- /** Route every subsequent UI turn through a cooperating cross-process lease. */
19
- export function configureSharedUiLeaseProvider(provider) {
20
- sharedUiLeaseProvider = provider;
21
- }
22
- /**
23
- * Mark one durable effect as dispatched immediately before its first GUI action.
24
- * Several UI turns may be needed to configure and send one effect; the shared scope
25
- * makes the durable transition exactly once across all of them.
26
- */
27
- export function withUiDispatchHook(hook, fn, actionId) {
28
- const context = { hook, actionId, fired: false };
29
- return uiDispatchScope.run(context, async () => {
30
- try {
31
- return await fn();
32
- }
33
- finally {
34
- // The coordinator commits success or ambiguity before its callback settles.
35
- // Release here so another process can never enter the window between the GUI
36
- // returning and that durable receipt decision.
37
- const lease = context.lease;
38
- context.lease = undefined;
39
- await lease?.release();
40
- }
41
- });
42
- }
43
- /**
44
- * Route the first external command executed inside a UI turn through the private
45
- * Workflow gate. Read-only probes before the UI turn stay in-process; later UI
46
- * turns are already behind a durable may-execute boundary and run normally.
47
- */
48
- export function withGatedUiCommand(onSpawned, fn) {
49
- if (uiCommandGateScope.getStore())
50
- throw new Error('a gated UI command scope is already active');
51
- return uiCommandGateScope.run({ onSpawned }, () => fn(exec));
52
- }
53
- function commandEnvironment(environment) {
54
- if (!environment)
55
- return undefined;
56
- return Object.fromEntries(Object.entries(environment).filter((entry) => typeof entry[1] === 'string' && process.env[entry[0]] !== entry[1]));
57
- }
58
- /** Execute an external helper, honoring a Workflow's one-shot private command gate. */
59
- async function exec(command, args, options = {}) {
60
- const gate = uiCommandGateScope.getStore();
61
- const execution = uiLeaseExecutionScope.getStore();
62
- if (execution && !execution.active)
63
- throw new Error('a UI command escaped its shared lease');
64
- if (!gate || execution?.gate !== gate)
65
- return execDirect(command, args, options);
66
- if (execution.commandRunning)
67
- throw new Error('gated UI commands must execute sequentially');
68
- // Claim synchronously so two accidentally-overlapping commands can never both
69
- // obtain a GO descriptor from one durable effect. Sequential commands refresh
70
- // the persisted external identity before each new GO.
71
- execution.commandRunning = true;
72
- try {
73
- const result = await runGatedWorkflowCommand({
74
- command,
75
- args,
76
- ...(options.cwd ? { cwd: options.cwd } : {}),
77
- ...(options.env ? { env: commandEnvironment(options.env) } : {}),
78
- ...(options.timeout === undefined ? {} : { timeoutMs: options.timeout }),
79
- ...(options.maxBuffer === undefined ? {} : { maxOutputBytes: options.maxBuffer }),
80
- ...(options.signal ? { signal: options.signal } : {}),
81
- onSpawned: async (process) => {
82
- // Neither commit alone releases the wrapper. GO crosses the pipe only after
83
- // both the effect and the cross-process mutex carry this exact identity.
84
- await gate.onSpawned(process);
85
- await execution.lease?.markMayExecute(process);
86
- }
87
- });
88
- return { stdout: result.stdout, stderr: result.stderr };
89
- }
90
- finally {
91
- execution.commandRunning = false;
92
- }
93
- }
94
- async function markUiDispatched(context) {
95
- if (!context || context.fired)
96
- return;
97
- if (!context.firing) {
98
- context.firing = Promise.resolve()
99
- .then(() => context.hook())
100
- .then(() => {
101
- context.fired = true;
102
- });
103
- }
104
- await context.firing;
105
- }
106
- /** Run `fn` with every UI operation it triggers marked at `priority`. */
107
- export function withUiPriority(priority, fn) {
108
- return uiPriorityScope.run(priority, fn);
109
- }
110
- export class UiBusyError extends Error {
111
- waiting;
112
- constructor(waiting) {
113
- super(`${UI_BUSY} — ${waiting} operation${waiting === 1 ? '' : 's'} already queued. Try again shortly.`);
114
- this.name = 'UiBusyError';
115
- this.waiting = waiting;
116
- }
117
- }
118
- /** Writes stringify `UiBusyError`, so background queues recognize both forms. */
119
- export function uiBusy(error) {
120
- return (error ?? '').startsWith(UI_BUSY);
121
- }
122
- let uiRunning = false;
123
- let uiSeq = 0;
124
- const uiWaiting = [];
125
- /** What the UI lock is doing right now — `waiting` excludes the run in flight. */
126
- export function uiQueueDepth() {
127
- return { waiting: uiWaiting.length, busy: uiRunning };
128
- }
129
- function pumpUi() {
130
- if (uiRunning)
131
- return;
132
- const next = uiWaiting.shift();
133
- if (!next)
134
- return;
135
- uiRunning = true;
136
- next.start();
137
- }
138
- /**
139
- * Take the lock, run `op`, release it. Exported for `tests/ui-lock.test.ts`,
140
- * which is the only way this queue's control flow gets read by anything.
141
- */
142
- export function uiTurn(op) {
143
- // High-level Workflow effects hold one cross-process lease through their durable
144
- // receipt poll. Existing actuator helpers still call uiTurn internally; reusing
145
- // the outer lease keeps that nesting from deadlocking or opening a correlation gap.
146
- if (uiLeaseExecutionScope.getStore()?.active)
147
- return Promise.resolve().then(op);
148
- const rank = uiPriorityScope.getStore() === 'background' ? 1 : 0;
149
- // A queued callback is eventually started from the previous owner's async chain,
150
- // so capture the effect scope now rather than consulting AsyncLocalStorage later.
151
- const dispatch = uiDispatchScope.getStore();
152
- const commandGate = uiCommandGateScope.getStore();
153
- if (uiWaiting.length >= MAX_UI_QUEUE)
154
- return Promise.reject(new UiBusyError(uiWaiting.length));
155
- return new Promise((resolve, reject) => {
156
- const waiter = {
157
- rank,
158
- seq: uiSeq++,
159
- start: () => {
160
- const priority = rank === 1 ? 'background' : 'interactive';
161
- const provider = sharedUiLeaseProvider;
162
- // Acquire the process-shared lease only after this operation owns the local
163
- // priority lock. The dispatch transition follows both locks and precedes the
164
- // first instruction in `op`, so a failure before it is provably replayable.
165
- const settled = (async () => {
166
- let lease = null;
167
- let releaseHere = true;
168
- try {
169
- lease = dispatch?.lease ?? null;
170
- if (!lease && provider) {
171
- lease = await provider.acquire({
172
- priority,
173
- ...(dispatch?.actionId ? { actionId: dispatch.actionId } : {})
174
- });
175
- if (dispatch)
176
- dispatch.lease = lease;
177
- }
178
- releaseHere = !dispatch;
179
- await markUiDispatched(dispatch);
180
- // An ordinary turn becomes potentially mutating immediately before its
181
- // operation. A Workflow's first external command instead supplies the
182
- // blocked wrapper identity and flips this boundary from inside `exec`.
183
- const pendingGate = commandGate;
184
- if (!pendingGate)
185
- await lease?.markMayExecute();
186
- const execution = {
187
- lease,
188
- commandRunning: false,
189
- active: true,
190
- ...(pendingGate ? { gate: pendingGate } : {})
191
- };
192
- try {
193
- return await uiLeaseExecutionScope.run(execution, op);
194
- }
195
- finally {
196
- execution.active = false;
197
- }
198
- }
199
- finally {
200
- // A lease release participates in the operation's result. In particular, do
201
- // not tell a caller the UI is free while another process still sees us as
202
- // its durable owner.
203
- if (releaseHere)
204
- await lease?.release();
205
- }
206
- })();
207
- // Release *before* resolving the caller, not after. Settling first and cleaning
208
- // up in a chained `.then` frees the lock one microtask late, so code that awaits
209
- // a write and then reads `uiQueueDepth()` is told a run is still in flight when
210
- // none is — and the next turn starts a tick later than it could.
211
- const release = () => {
212
- uiRunning = false;
213
- pumpUi();
214
- };
215
- settled.then(value => {
216
- release();
217
- resolve(value);
218
- }, err => {
219
- release();
220
- reject(err);
221
- });
222
- }
223
- };
224
- // Stable insert: by rank, FIFO within a rank. A background run already started
225
- // keeps the lock — this decides who is next, never who is interrupted.
226
- const at = uiWaiting.findIndex(w => w.rank > rank);
227
- if (at < 0)
228
- uiWaiting.push(waiter);
229
- else
230
- uiWaiting.splice(at, 0, waiter);
231
- pumpUi();
232
- });
233
- }
234
- /**
235
- * How long one AppleScript run may take before it's killed.
236
- *
237
- * Sized from measurement, not taste. A send that *worked* measured 23.6s end to
238
- * end on a 30-workspace sidebar — past the 20s ceiling this replaces, which is why
239
- * ordinary sends were being killed mid-run and reported as "Conductor took too long
240
- * to respond". The cost is Accessibility round trips, not waiting: activating a
241
- * backgrounded Conductor and reading the pane cost ~10s cold, and finding the
242
- * sidebar row to press another ~10s.
243
- *
244
- * `openViaDeepLink` took the row scan out of the common path — a whole send now
245
- * measures ~4s, and focusing alone ~2s against the ~18s the same focus costs when
246
- * the link is unavailable — so this budget is really the *fallback's*, kept at the
247
- * size that fallback still needs. A ceiling costs nothing when a send is fast; only
248
- * a doomed one waits it out, and the caller's own deadline is what bounds that.
249
- */
250
- export const SEND_ATTEMPT_MS = 28_000;
251
- /**
252
- * A restart's own ceiling. It is longer than a send's because it is mostly *waiting*:
253
- * up to 4s for the quit to be honoured, then a cold launch, then `waitForWindow(60)`'s
254
- * 15s for the first window — none of which can be hurried, and all of which the caller
255
- * would rather wait through than be told "try again" about.
256
- */
257
- export const RESTART_ATTEMPT_MS = 45_000;
258
- /**
259
- * A run's own ceiling, taken off the caller's deadline at the moment it actually
260
- * starts.
261
- *
262
- * Both halves matter. `uiTurn` above means a run can sit in the queue behind
263
- * another write, so a duration computed when it was *requested* would let a queued
264
- * run overshoot a deadline the caller is still holding a phone open on.
265
- * `SEND_ATTEMPT_MS` then caps it, because a caller with a minute of budget still
266
- * shouldn't spend all of it on one doomed run when a retry is the thing that works.
267
- * The floor keeps a squeezed run honest instead of passing `timeout: 0`, which node
268
- * reads as "no timeout at all".
269
- */
270
- function runCeiling(deadline) {
271
- return Math.max(5_000, Math.min(SEND_ATTEMPT_MS, deadline - Date.now()));
272
- }
273
- /**
274
- * Failures no amount of retrying will fix, so a caller that retries can stop at
275
- * once instead of spending a whole budget to arrive at the same sentence.
276
- *
277
- * Matched on phrases this file writes itself — the first two from `refusalReason`,
278
- * the rest from the target checks below — never on macOS's own wording, which we
279
- * quote verbatim precisely because it drifts. The refusals are the ones a node
280
- * upgrade causes by silently revoking Accessibility: they fail instantly and
281
- * identically every time, so making the phone sit through a whole retry budget to
282
- * be told a permission is missing is worse than being told at once. The rest are
283
- * malformed targets — no session, no branch — which no attempt can turn into one.
284
- */
285
- const TERMINAL_ERRORS = [
286
- 'not trusted for Accessibility',
287
- 'blocked the relay from controlling the UI',
288
- 'no session id to target',
289
- 'workspace has no branch to focus'
290
- ];
291
- export function retryWontHelp(error) {
292
- return error !== undefined && TERMINAL_ERRORS.some(phrase => error.includes(phrase));
293
- }
294
- /**
295
- * A send that failed because the Mac's screen is locked. Deliberately *not* in
296
- * `TERMINAL_ERRORS`: it isn't terminal (an unlock fixes it) but it also isn't a
297
- * warm-up cost a retry loop should burn budget on — the caller parks the prompt
298
- * instead (src/parked.ts) and the queue delivers it when the lock lifts. Matched
299
- * on the phrase every lock refusal in conductor.applescript starts with; the
300
- * words are ours, so they can't drift under us the way macOS's can. The phrase
301
- * itself lives in src/shared.ts because the phone matches it too — it is what
302
- * puts the Screen Sharing link beside a refusal nobody else can clear.
303
- */
304
- export function lockBlocked(error) {
305
- return isLockedError(error);
306
- }
307
- /**
308
- * A run that ended with the prompt still sitting in Conductor's composer
309
- * (`submitComposer`). The draft was never consumed, so this run wrote no row and
310
- * the caller's confirm window has nothing to wait for — six seconds spent watching
311
- * for something the run already proved didn't happen. Only the *waiting* is
312
- * skipped: an earlier attempt's row can still be arriving, so the caller checks
313
- * once before typing again.
314
- *
315
- * Matched on the phrase the send script writes itself, like `lockBlocked` above,
316
- * so macOS wording can't drift under it.
317
- */
318
- export function sendNeverStarted(error) {
319
- return (error ?? '').includes('still sitting in its composer');
320
- }
321
- /**
322
- * Node's own read of the lock screen — the same CGSessionCopyCurrentDictionary
323
- * probe `screenLocked()` in conductor.applescript makes, minus the AppleScript
324
- * wrapper, so the parked-prompt queue can poll it without spinning up a whole
325
- * UI script (and without needing Accessibility at all). `null` means the probe
326
- * itself failed — callers should treat that as "try the send and let it tell
327
- * you", not as either lock state.
328
- */
329
- export async function screenLocked() {
330
- // Keep in lockstep with `screenLocked()` in conductor.applescript, traps and
331
- // all: $.CFBridgingRelease segfaults under JXA, and without the bindFunction
332
- // rebind deepUnwrap reads the CF dictionary as undefined — a silent "unlocked"
333
- // on a locked Mac.
334
- const jxa = "ObjC.import('CoreGraphics'); ObjC.bindFunction('CGSessionCopyCurrentDictionary', ['id', []]); const d = ObjC.deepUnwrap($.CGSessionCopyCurrentDictionary()) || {}; d.CGSSessionScreenIsLocked ? 'locked' : 'unlocked'";
335
- try {
336
- const { stdout } = await exec('osascript', ['-l', 'JavaScript', '-e', jxa], { timeout: 5_000 });
337
- const out = stdout.trim();
338
- if (out === 'locked')
339
- return true;
340
- if (out === 'unlocked')
341
- return false;
342
- return null;
343
- }
344
- catch {
345
- return null;
346
- }
347
- }
348
- /**
349
- * The sidecar IPC path — the precise, per-session write. Delivers straight to
350
- * `sessionId` over Conductor's own dispatch socket (see sidecar.ts), so it needs
351
- * no window focus and the app UI reflects the turn correctly.
352
- *
353
- * Opt-in (WRITE_STRATEGY=sidecar) because it speaks a private, versioned IPC and
354
- * hasn't been validated by an automated live send (that would inject a prompt
355
- * into a running agent). It is the intended default once you've confirmed it on
356
- * your setup.
357
- */
358
- export class SidecarActuator {
359
- name = 'sidecar';
360
- caveat = 'Delivered straight to the target session over Conductor’s dispatch socket — precise per-workspace targeting.';
361
- precise = true;
362
- available() {
363
- return sidecarAvailable();
364
- }
365
- /** `deadline` is ignored — the sidecar is one socket write, with no UI to wait on. */
366
- async send(target, text, options = {}) {
367
- const sessionId = target.sessionId ?? target.workspace.active_session_id;
368
- if (!sessionId)
369
- return { ok: false, strategy: this.name, error: 'no session id to target' };
370
- try {
371
- await sidecarSendUserMessage(sessionId, text, options.queue ? 'queue' : 'default');
372
- return { ok: true, strategy: this.name };
373
- }
374
- catch (err) {
375
- return { ok: false, strategy: this.name, error: err instanceof Error ? err.message : String(err) };
376
- }
377
- }
378
- }
379
- /**
380
- * Every AppleScript handler the write path uses, read from src/conductor.applescript.
381
- *
382
- * A sibling asset, so it resolves off this module's own directory rather than
383
- * `packageRoot()`. The usual rule here (never anchor on `import.meta.dirname/..`) exists
384
- * because the compiled files sit one level deeper in the tarball, which is exactly
385
- * why a *sibling* is the one thing that may anchor there: `yarn build:node` copies the
386
- * script next to the emitted JS, so the same join resolves unbuilt and installed.
387
- * Read once at import, because a missing copy is a packaging bug and a relay that
388
- * refuses to boot with an ENOENT naming the path is the loudest way to say so.
389
- */
390
- const CONDUCTOR_HANDLERS = readFileSync(path.join(import.meta.dirname, 'conductor.applescript'), 'utf8').trimEnd();
391
- /**
392
- * The URL scheme Conductor registers, which is per *release channel*: the
393
- * production build answers `conductor://`, the pre-release ones
394
- * `conductor-alpha://`, `conductor-beta://`, `conductor-dev://` and friends.
395
- * Everything else in this file addresses `application "Conductor"` by name, so
396
- * production is the only channel the write path works against anyway — the
397
- * override exists so a channel build needs a variable rather than a patch.
398
- */
399
- const CONDUCTOR_SCHEME = process.env.RELAY_CONDUCTOR_SCHEME || 'conductor';
400
- /**
401
- * Conductor's own link to a workspace, and optionally to one chat inside it —
402
- * exactly what its sidebar row menu copies under "Copy link" (Cmd+Shift+C).
403
- *
404
- * `conductor://workspace?id=<workspace>&session=<chat>` is the shape that works,
405
- * and the near misses all fail *badly*, so this is the one place that builds it:
406
- * the parameters sit behind a real `?` (unlike the create-workspace links, which
407
- * are flat after the scheme), the workspace id is `id` rather than `workspace`,
408
- * and `workspace` must be the URL's **host** — `conductor:///workspace/<id>`,
409
- * with the id in the path, falls through to the flat-parameter parser and
410
- * **creates a new workspace in the first repo**. A path-shaped link with the
411
- * right host (`conductor://workspace/<id>`) is merely ignored.
412
- *
413
- * `session` is optional and names a chat by `sessions.id`, the same id the relay
414
- * already serves; omitted, Conductor opens whichever tab that workspace had.
415
- * Current Conductor builds also unhide a session named by this route, through
416
- * their own session service. `restoreChat` uses that behavior and confirms the
417
- * exact id became visible; older builds may ignore it, so opening is no receipt.
418
- *
419
- * The https form Conductor copies for sharing —
420
- * `https://app.conductor.build/workspace/<id>?session=<chat>` — reaches the same
421
- * handler, but only once macOS has decided to hand it to the app; the desktop
422
- * build declares no associated domain, so a browser gets it first. Locally the
423
- * scheme form is the one that always lands.
424
- */
425
- function workspaceLink(workspaceId, sessionId) {
426
- const params = new URLSearchParams({ id: workspaceId });
427
- if (sessionId)
428
- params.set('session', sessionId);
429
- return `${CONDUCTOR_SCHEME}://workspace?${params.toString()}`;
430
- }
431
- /** Conductor's command palette matches workspaces by branch — its unique key. A
432
- * looser query (directory name) can match a command like unarchive, so prefer
433
- * branch and only fall back when it's absent. */
434
- function focusQuery(ws) {
435
- return ws.branch || ws.workspace_name || ws.directory_name || '';
436
- }
437
- /**
438
- * Every title Conductor might be showing for this workspace in the sidebar
439
- * (its precedence: manual name → PR title → humanized branch → codename). The
440
- * sidebar press tries each and requires a unique row; a miss just means we fall
441
- * back to the palette, so this doesn't have to reproduce the precedence exactly.
442
- */
443
- function sidebarTitles(ws) {
444
- const slug = ws.branch?.includes('/') ? ws.branch.slice(ws.branch.indexOf('/') + 1) : ws.branch;
445
- const humanized = slug?.replace(/[-_]/g, ' ').trim();
446
- return [
447
- ws.workspace_name,
448
- ws.pr_title,
449
- humanized ? humanized[0].toUpperCase() + humanized.slice(1) : '',
450
- ws.directory_name
451
- ].filter((t) => Boolean(t));
452
- }
453
- /**
454
- * May the actuator restart Conductor to force a window into existence?
455
- *
456
- * Restarting is the only lever left when a running, windowless Conductor ignores
457
- * both `reopen` and a Dock click — it is single-window and single-instance, so
458
- * there is nothing else to press. It is also the one step here that can destroy
459
- * work: quitting takes any agent mid-turn down with it. "Nothing is running" is a
460
- * fact only the read side has, so server.ts wires this to a DB read rather than
461
- * writes.ts guessing. Unset → never restart, which is the safe default for any
462
- * caller that hasn't opted in.
463
- */
464
- let restartGuard = null;
465
- export function setRestartGuard(guard) {
466
- restartGuard = guard;
467
- }
468
- function restartAllowed() {
469
- try {
470
- return restartGuard?.() ?? false;
471
- }
472
- catch {
473
- return false;
474
- }
475
- }
476
- /** Fixed targeting values travel in the environment; the three UI labels ride in RELAY_TARGET_FILE. */
477
- function targetEnv(target) {
478
- return {
479
- RELAY_ALLOW_RESTART: restartAllowed() ? '1' : '',
480
- RELAY_TAB_INDEX: String(target.tab?.index ?? 0),
481
- RELAY_TAB_COUNT: String(target.tab?.count ?? 0),
482
- RELAY_WS_BRANCH: target.workspace.branch ?? '',
483
- RELAY_WS_REPO: target.workspace.repo_name ?? '',
484
- RELAY_WS_LINK: workspaceLink(target.workspace.id, target.sessionId)
485
- };
486
- }
487
- /**
488
- * The target file is line-based: tab title, palette query, then sidebar-title candidates. Conductor labels
489
- * cannot contain a newline, and `do shell script` reads this file as UTF-8 rather than MacRoman.
490
- */
491
- function targetLines(target) {
492
- return [target.tab?.title ?? '', focusQuery(target.workspace), ...sidebarTitles(target.workspace)];
493
- }
494
- /**
495
- * Give one UI action the three matching labels in a UTF-8 file. `system attribute` decodes text as MacRoman,
496
- * but it safely carries the temporary file path.
497
- */
498
- export async function withTargetEnvironment(target, action) {
499
- const fs = await import('node:fs/promises');
500
- const os = await import('node:os');
501
- const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'relay-target-'));
502
- const file = path.join(directory, 'text');
503
- try {
504
- await fs.writeFile(file, targetLines(target).join('\n'), 'utf8');
505
- return await action({ ...targetEnv(target), RELAY_TARGET_FILE: file });
506
- }
507
- finally {
508
- await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined);
509
- }
510
- }
511
- /** osascript echoes the whole failing script back; keep just the reason for the phone. */
512
- function osaError(err, timeoutMsg = 'Conductor took too long to respond') {
513
- const raw = err instanceof WorkflowGatedCommandError
514
- ? [err.message, err.stderr].filter(Boolean).join('\n')
515
- : err instanceof Error
516
- ? err.message
517
- : String(err);
518
- // A timeout kill carries no execution error at all — its first line is
519
- // "Command failed: osascript -e" plus the whole script, which is useless here.
520
- if ((err instanceof WorkflowGatedCommandError && err.code === 'timed_out') ||
521
- (err && typeof err === 'object' && 'killed' in err && err.killed)) {
522
- return timeoutMsg;
523
- }
524
- return raw.match(/execution error: (.+?) \(-?\d+\)/)?.[1] ?? raw.split('\n')[0];
525
- }
526
- /**
527
- * Drives Conductor's real send path via macOS Accessibility (AppleScript): focus
528
- * the target workspace, paste the prompt, press Enter. Uses whatever model /
529
- * permission mode the session already has (zero risk of altering the agent),
530
- * which is why it's the default.
531
- *
532
- * Precise targeting comes from opening Conductor's own workspace link first
533
- * (`conductor://workspace?id=…&session=…`, see `workspaceLink`) and then
534
- * confirming the pane and the chat tab through Accessibility (see
535
- * src/conductor.applescript), so the prompt lands in the right session regardless
536
- * of what was focused. The link is public and id-addressed; the AX reads only
537
- * check it, and pressing the sidebar row or the command palette remains the
538
- * fallback for a Conductor that doesn't answer it.
539
- */
540
- export class AppleScriptActuator {
541
- name = 'applescript';
542
- caveat = "Opens the target workspace's own Conductor link, then confirms the chat tab before sending.";
543
- precise = true;
544
- async send(target, text, options = {}) {
545
- const deadline = options.deadline ?? Date.now() + SEND_ATTEMPT_MS;
546
- // Open the target workspace's own link, confirm its chat tab, fill the composer, send.
547
- // Filling is an Accessibility write (no keystrokes, no clipboard); the
548
- // clipboard paste is kept only as a fallback, and stashes/restores around it.
549
- //
550
- // The send is then read back rather than assumed (`submitComposer`): Conductor
551
- // consumes the draft when it takes a prompt, so a composer that still holds the
552
- // text is an Enter that went nowhere, and pressing again inside this run costs
553
- // under a second. Left to `deliverPrompt` the same failure costs the 6s confirm
554
- // window plus a whole second run, which is the ~10s of prompt-sitting-on-screen
555
- // this Mac's logs recorded. A composer that survives three presses is stuck on
556
- // something the script can't see, so it errors and lets that retry take over.
557
- const script = `
558
- ${CONDUCTOR_HANDLERS}
559
-
560
- my activateConductor()
561
- my focusWorkspace()
562
- my selectChatTab()
563
- set promptText to my normalizeNewlines(do shell script "cat" & " " & quoted form of (system attribute "RELAY_PROMPT_FILE"))
564
- set textBox to my composerField()
565
- if not (my fillComposer(textBox, promptText)) then
566
- set savedClipboard to the clipboard
567
- my pasteComposer()
568
- delay 0.1
569
- set the clipboard to savedClipboard
570
- end if
571
- set presses to my submitComposer(textBox, promptText, (system attribute "RELAY_QUEUE_PROMPT") is "1")
572
- if presses is 0 then error "Conductor ignored Enter - the prompt is still sitting in its composer"
573
- return "presses:" & presses
574
- `.trim();
575
- // Pass the prompt via a temp file + env to avoid AppleScript string escaping.
576
- const os = await import('node:os');
577
- const fs = await import('node:fs/promises');
578
- const path = await import('node:path');
579
- const tmp = path.join(os.tmpdir(), `relay-prompt-${process.pid}-${Date.now()}.txt`);
580
- await fs.writeFile(tmp, text, 'utf8');
581
- try {
582
- const { stdout } = await withTargetEnvironment(target, targetEnvironment => uiTurn(() => exec('osascript', ['-e', script], {
583
- env: {
584
- ...process.env,
585
- RELAY_PROMPT_FILE: tmp,
586
- RELAY_QUEUE_PROMPT: options.queue ? '1' : '',
587
- ...targetEnvironment
588
- },
589
- timeout: runCeiling(deadline)
590
- })));
591
- // A rescued send is otherwise indistinguishable from one that worked first
592
- // time, so the failure would leave the log whether it was fixed or hidden.
593
- const presses = Number(stdout.match(/presses:(\d+)/)?.[1] ?? 1);
594
- if (presses > 1) {
595
- console.warn(`[relay] Conductor ignored Enter — the composer cleared on press ${presses}`);
596
- }
597
- return { ok: true, strategy: this.name };
598
- }
599
- catch (err) {
600
- return { ok: false, strategy: this.name, error: osaError(err) };
601
- }
602
- finally {
603
- await fs.rm(tmp, { force: true }).catch(() => undefined);
604
- }
605
- }
606
- }
607
- /**
608
- * Stop the answer a chat is streaming — the desktop app's stop button, reached
609
- * through the same focus-and-assert path as a send and then Conductor's own
610
- * "Cancel agent" shortcut (see `cancelAgent` in conductor.applescript for why a
611
- * keystroke rather than the button).
612
- *
613
- * The branch is required rather than optional. Everywhere else a workspace with
614
- * no branch merely loses the pane assertion; here that assertion is the only thing
615
- * standing between "stop this agent" and "throw away a different agent's turn", so
616
- * a target that can't be checked is refused instead of aimed.
617
- *
618
- * Nothing is confirmed here: `sessions.status` leaving `working` is the receipt and
619
- * server.ts waits for it, the same way agent settings are confirmed against the DB
620
- * rather than against the UI that was just driven.
621
- */
622
- export async function stopTurn(target) {
623
- if (!target.workspace.branch) {
624
- return { ok: false, strategy: 'applescript', error: 'workspace has no branch to focus' };
625
- }
626
- const script = `
627
- ${CONDUCTOR_HANDLERS}
628
-
629
- my activateConductor()
630
- my focusWorkspace()
631
- my selectChatTab()
632
- my cancelAgent()
633
- return "ok"`.trim();
634
- try {
635
- await withTargetEnvironment(target, targetEnvironment => uiTurn(() => exec('osascript', ['-e', script], {
636
- env: { ...process.env, ...targetEnvironment },
637
- timeout: SEND_ATTEMPT_MS
638
- })));
639
- return { ok: true, strategy: 'applescript' };
640
- }
641
- catch (err) {
642
- return { ok: false, strategy: 'applescript', error: osaError(err) };
643
- }
644
- }
645
- /**
646
- * Hide one chat through Conductor's own Close tab action (Command-W).
647
- *
648
- * Like `stopTurn`, the branch is required because Command-W acts on whatever has
649
- * keyboard focus. `selectChatTab` proves the target and `closeChatTab` moves focus
650
- * back out of the terminal before pressing it. Closing is reversible in Conductor,
651
- * but a running chat still gets its desktop confirmation: `closeRunning` is the
652
- * caller's explicit answer to that prompt, never something this layer infers.
653
- *
654
- * The script only reports that it pressed the action. `sessions.is_hidden` is the
655
- * receipt; server.ts waits until `Reads.listSessions` no longer returns this id.
656
- */
657
- export async function closeChat(target, closeRunning) {
658
- if (!target.workspace.branch) {
659
- return { ok: false, strategy: 'applescript', error: 'workspace has no branch to focus' };
660
- }
661
- const script = `
662
- ${CONDUCTOR_HANDLERS}
663
-
664
- my activateConductor()
665
- my focusWorkspace()
666
- my selectChatTab()
667
- my closeChatTab()
668
- return "ok"`.trim();
669
- try {
670
- await withTargetEnvironment(target, targetEnvironment => uiTurn(() => exec('osascript', ['-e', script], {
671
- env: {
672
- ...process.env,
673
- ...targetEnvironment,
674
- RELAY_CLOSE_RUNNING: closeRunning ? '1' : ''
675
- },
676
- timeout: SEND_ATTEMPT_MS
677
- })));
678
- return { ok: true, strategy: 'applescript' };
679
- }
680
- catch (err) {
681
- return { ok: false, strategy: 'applescript', error: osaError(err) };
682
- }
683
- }
684
- /**
685
- * Conductor's workspace route restores a hidden session before selecting it.
686
- * Keep the UI lease through the DB receipt: another focus operation must not
687
- * navigate away while Conductor's asynchronous route handler is unhiding it.
688
- * The callback is deliberately exact-id and read-only, supplied by the server.
689
- */
690
- export async function restoreChat(workspaceId, sessionId, isVisible) {
691
- return uiTurn(async () => {
692
- if (isVisible())
693
- return { ok: true, strategy: 'deep-link', alreadyOpen: true };
694
- if ((await screenLocked()) === true) {
695
- return { ok: false, strategy: 'deep-link', error: `${MAC_LOCKED}. Unlock it, then try again.` };
696
- }
697
- try {
698
- await exec('open', [workspaceLink(workspaceId, sessionId)], { timeout: 5_000 });
699
- for (let attempt = 0; attempt < 40; attempt++) {
700
- await new Promise(resolve => setTimeout(resolve, 250));
701
- if (isVisible())
702
- return { ok: true, strategy: 'deep-link' };
703
- }
704
- return {
705
- ok: false,
706
- strategy: 'deep-link',
707
- error: 'Conductor has not restored this tab yet. Try again, or update Conductor on your Mac.'
708
- };
709
- }
710
- catch (error) {
711
- return { ok: false, strategy: 'deep-link', error: osaError(error) };
712
- }
713
- });
714
- }
715
- /**
716
- * Press the Wi-Fi menu's row for a personal hotspot — Instant Hotspot, the same
717
- * button a human clicks. `networksetup` can only join a network that is
718
- * broadcasting, and a personal hotspot usually isn't; the row in Control
719
- * Center's Wi-Fi popover is fed by Continuity over Bluetooth, and pressing it
720
- * asks the phone to wake its hotspot. The funnel watchdog reaches for this when
721
- * a plain join answered "Could not find network".
722
- *
723
- * The one UI write here that doesn't target Conductor, and it still takes a
724
- * uiTurn: the popover steals key focus, so a palette fallback running at the
725
- * same moment would type into it. The name travels via a temp file like the
726
- * prompt does — same escaping-and-encoding dodge, and hotspot names ("Han
727
- * høyes iPhone") are non-ASCII more often than prompts are. Success here means
728
- * *pressed*, nothing more: joining takes several seconds of Bluetooth wake +
729
- * DHCP, so the caller owns the wait, and it watches `hasDefaultRoute()` — the
730
- * one link signal that needs no permission — not this function's word.
731
- * Everything else — the lock check, the toggle-aware close, the already-open
732
- * abort, the contention story — lives with the handler in conductor.applescript.
733
- */
734
- export async function joinInstantHotspot(name) {
735
- const script = `
736
- ${CONDUCTOR_HANDLERS}
737
-
738
- my joinInstantHotspot()`.trim();
739
- const os = await import('node:os');
740
- const fs = await import('node:fs/promises');
741
- const tmp = path.join(os.tmpdir(), `relay-hotspot-${process.pid}-${Date.now()}.txt`);
742
- await fs.writeFile(tmp, name, 'utf8');
743
- try {
744
- await uiTurn(() => exec('osascript', ['-e', script], {
745
- env: { ...process.env, RELAY_HOTSPOT_FILE: tmp },
746
- timeout: 25_000
747
- }));
748
- return { ok: true };
749
- }
750
- catch (err) {
751
- return { ok: false, error: osaError(err, 'the Wi-Fi menu press took too long') };
752
- }
753
- finally {
754
- await fs.rm(tmp, { force: true }).catch(() => undefined);
755
- }
756
- }
757
- /**
758
- * Conductor stores the effort level in a provider-specific session column
759
- * (`codex_thinking_level` or `claude_effort_level`), normalized by Reads onto the
760
- * relay's stable wire field. The composer button is labelled with the human name
761
- * and *cycles* through these values, so both directions are needed: the label to
762
- * press toward, and the normalized DB value to confirm against.
763
- */
764
- export const EFFORT_LABELS = {
765
- none: 'None',
766
- low: 'Low',
767
- medium: 'Medium',
768
- high: 'High',
769
- xhigh: 'Extra high',
770
- max: 'Max',
771
- ultracode: 'Ultracode'
772
- };
773
- const CODEX_EFFORT_LABELS = {
774
- none: '__UNNAMED_EFFORT__',
775
- low: 'Light',
776
- medium: 'Medium',
777
- high: 'High',
778
- xhigh: 'Extra high',
779
- max: 'Max',
780
- ultracode: 'Ultra'
781
- };
782
- /** Translate the stable wire value to the provider's measured composer label. */
783
- export function effortUiLabel(effort, agentType) {
784
- if (agentType === 'codex')
785
- return CODEX_EFFORT_LABELS[effort];
786
- if (agentType === 'claude' && effort !== 'none')
787
- return EFFORT_LABELS[effort];
788
- return undefined;
789
- }
790
- /**
791
- * A boolean patch says what state the caller wants, not that the matching UI
792
- * control must be pressed. Avoid looking for Plan when Conductor already records
793
- * the requested mode — some models do not render that control at all when it is
794
- * off. Unknown state remains fail-closed and is sent through to the actuator.
795
- */
796
- export function planSettingForUi(wanted, currentPermissionMode) {
797
- if (wanted === undefined)
798
- return undefined;
799
- return currentPermissionMode === (wanted ? 'plan' : 'default') ? undefined : wanted;
800
- }
801
- /**
802
- * Apply agent settings to a specific chat: focus its workspace and tab (same
803
- * verified path as a send), then drive the composer's own controls. Every step
804
- * confirms the control landed on the requested value and errors out otherwise,
805
- * so a half-applied change is reported rather than assumed.
806
- */
807
- export async function setAgentOptions(target, opts) {
808
- if (opts.effort && !effortUiLabel(opts.effort, opts.agentType)) {
809
- return { ok: false, strategy: 'applescript', error: `unknown effort level ${opts.effort}` };
810
- }
811
- const script = `
812
- ${CONDUCTOR_HANDLERS}
813
-
814
- my activateConductor()
815
- my focusWorkspace()
816
- my selectChatTab()
817
- my applyAgentOptions()
818
- return "ok"`.trim();
819
- try {
820
- await withTargetEnvironment(target, targetEnvironment => uiTurn(() => exec('osascript', ['-e', script], {
821
- env: {
822
- ...process.env,
823
- ...targetEnvironment,
824
- RELAY_SET_EFFORT: opts.effort ? effortUiLabel(opts.effort, opts.agentType) : '',
825
- RELAY_SET_PLAN: opts.plan === undefined ? '' : opts.plan ? '1' : '0',
826
- RELAY_SET_FAST: opts.toggleFast ? '1' : '',
827
- RELAY_SET_MODEL: opts.model ?? ''
828
- },
829
- timeout: SEND_ATTEMPT_MS
830
- })));
831
- return { ok: true, strategy: 'applescript' };
832
- }
833
- catch (err) {
834
- return { ok: false, strategy: 'applescript', error: osaError(err) };
835
- }
836
- }
837
- /**
838
- * Star one picker row as Conductor's user-wide default model.
839
- *
840
- * The desktop exposes this as a child button named "Set … as default and select",
841
- * so this deliberately has both effects: it changes the global default and selects
842
- * that model for `target`. The AppleScript reopens the picker and reads the unique
843
- * starred row back before this reports success.
844
- */
845
- export async function setDefaultModel(target, model) {
846
- const script = `
847
- ${CONDUCTOR_HANDLERS}
848
-
849
- my activateConductor()
850
- my focusWorkspace()
851
- my selectChatTab()
852
- return my setDefaultModel(system attribute "RELAY_DEFAULT_MODEL")`.trim();
853
- try {
854
- const { stdout } = await withTargetEnvironment(target, targetEnvironment => uiTurn(() => exec('osascript', ['-e', script], {
855
- env: { ...process.env, ...targetEnvironment, RELAY_DEFAULT_MODEL: model },
856
- timeout: SEND_ATTEMPT_MS
857
- })));
858
- const selected = modelPickerLabel(stdout.trim());
859
- if (!selected)
860
- return { ok: false, strategy: 'applescript', error: 'Conductor returned no default model' };
861
- return { ok: true, strategy: 'applescript', model: selected };
862
- }
863
- catch (err) {
864
- return { ok: false, strategy: 'applescript', error: osaError(err) };
865
- }
866
- }
867
- /**
868
- * Put a workspace away — Conductor's own "Archive workspace" (Command-Shift-A),
869
- * pressed on the workspace this run has just focused and asserted.
870
- *
871
- * The chord acts on whatever the pane is showing, so the assertion is the entire
872
- * guard and the branch is required rather than optional, exactly as it is for
873
- * `stopTurn`. The damage is worse here than a mis-aimed prompt: archiving deletes
874
- * the worktree and takes any agent mid-turn down with it, so a workspace whose pane
875
- * cannot be checked is refused instead of guessed at.
876
- *
877
- * `stopAgents` is what the caller has said about that second half. Conductor draws a
878
- * confirmation ("Stop agents and archive") when the workspace still has an agent
879
- * working, and the script presses it only with this set; without it the dialog is
880
- * dismissed and the run fails, so a tap that meant "archive" can never quietly end
881
- * someone else's turn. An idle workspace draws no dialog at all.
882
- *
883
- * Nothing is confirmed here. `workspaces.state` becoming `archived` is the receipt
884
- * and server.ts waits for it, the same way the stop and the status change do.
885
- */
886
- export async function archiveWorkspace(workspace, stopAgents) {
887
- if (!workspace.branch) {
888
- return { ok: false, strategy: 'applescript', error: 'workspace has no branch to focus' };
889
- }
890
- const script = `
891
- ${CONDUCTOR_HANDLERS}
892
-
893
- my activateConductor()
894
- my focusWorkspace()
895
- my archiveWorkspace()
896
- return "ok"`.trim();
897
- try {
898
- await withTargetEnvironment({ workspace, sessionId: null }, targetEnvironment => uiTurn(() => exec('osascript', ['-e', script], {
899
- env: { ...process.env, ...targetEnvironment, RELAY_ARCHIVE_AGENTS: stopAgents ? '1' : '' },
900
- timeout: SEND_ATTEMPT_MS
901
- })));
902
- return { ok: true, strategy: 'applescript' };
903
- }
904
- catch (err) {
905
- return { ok: false, strategy: 'applescript', error: osaError(err) };
906
- }
907
- }
908
- /**
909
- * Continue a merged workspace on a new branch through Conductor's own Continue
910
- * button. This deliberately does not perform `git checkout` itself: the native
911
- * action also updates Conductor's workspace identity, preserves the existing
912
- * sessions, clears the merged PR metadata, and stages its Branch continued.md
913
- * context in the selected chat. Reproducing only the git half would leave the DB
914
- * and UI describing the branch we just moved away from; writing the DB is forbidden.
915
- *
916
- * The exact chat matters even though every chat survives. Conductor attaches the
917
- * continuation note to the selected one, so the target carries the phone's current
918
- * session and `selectChatTab` asserts it before the button is found. The button is
919
- * available only in Conductor's merged action bar; an absent or ambiguous control
920
- * fails closed. server.ts confirms success from the resulting branch change.
921
- */
922
- export async function continueWorkspace(target) {
923
- if (!target.workspace.branch) {
924
- return { ok: false, strategy: 'applescript', error: 'workspace has no branch to focus' };
925
- }
926
- const script = `
927
- ${CONDUCTOR_HANDLERS}
928
-
929
- my activateConductor()
930
- my focusWorkspace()
931
- my selectChatTab()
932
- my continueWorkspaceOnNewBranch()
933
- return "ok"`.trim();
934
- try {
935
- await withTargetEnvironment(target, targetEnvironment => uiTurn(() => exec('osascript', ['-e', script], {
936
- env: { ...process.env, ...targetEnvironment },
937
- timeout: SEND_ATTEMPT_MS
938
- })));
939
- return { ok: true, strategy: 'applescript' };
940
- }
941
- catch (err) {
942
- return { ok: false, strategy: 'applescript', error: osaError(err) };
943
- }
944
- }
945
- /**
946
- * Quit Conductor and start it again because the phone asked — the one write here
947
- * whose subject is the app rather than anything inside it.
948
- *
949
- * The lever already existed and was unreachable. `activateConductor` restarts as its
950
- * last resort, but only after a *running* Conductor has drawn no window through
951
- * `reopen` and a Dock click, so it answers exactly one failure: a windowless app. The
952
- * failure this is for looks healthy from every probe on that ladder — window up,
953
- * sidebar drawing, composer taking prompts — while the agent runtime behind it has
954
- * stopped producing anything. Measured on this Mac (2026-09-02): the last agent frame
955
- * in `session_messages` was 20:47:44 and prompts kept landing as user rows for the
956
- * next two and a half hours, each turn flipping `working → idle` having written
957
- * nothing. Nothing on the read side can fix that, and "quit it on your Mac" is not
958
- * advice a phone can act on.
959
- *
960
- * Two gates, and neither lives here. The **working chats** are counted from the DB by
961
- * server.ts, which refuses without `stopAgents` — quitting takes every agent mid-turn
962
- * down with it, so that has to be said out loud, exactly as it is for archiving. The
963
- * **lock screen** is asked by `restartApp` itself, because a relaunch fired behind it
964
- * comes up windowless (and once, wedged). What is left for this function is the UI
965
- * lock: a restart is not a read, and letting one land while a send is mid-flight would
966
- * quit the app between the composer write and the Enter.
967
- */
968
- export async function restartConductorApp() {
969
- const script = `
970
- ${CONDUCTOR_HANDLERS}
971
-
972
- return my restartApp()`.trim();
973
- try {
974
- await uiTurn(() => exec('osascript', ['-e', script], { env: { ...process.env }, timeout: RESTART_ATTEMPT_MS }));
975
- return { ok: true, strategy: 'applescript' };
976
- }
977
- catch (err) {
978
- return { ok: false, strategy: 'applescript', error: osaError(err, 'Conductor didn’t come back in time') };
979
- }
980
- }
981
- /**
982
- * The workspace statuses Conductor's sidebar groups by, mapped from the value it
983
- * stores in `workspaces.manual_status` to the label on its own menu. `canceled`
984
- * is on the menu but has never been written in this DB, so it's the one spelling
985
- * here that is taken from the UI rather than confirmed against stored data — a
986
- * mismatch surfaces as a failed confirmation naming what Conductor actually wrote.
987
- */
988
- export const WORKSPACE_STATUS_LABELS = {
989
- backlog: 'Backlog',
990
- 'in-progress': 'In progress',
991
- 'in-review': 'In review',
992
- done: 'Done',
993
- canceled: 'Canceled'
994
- };
995
- /**
996
- * Move a workspace between the sidebar's status groups — the thing a merged PR
997
- * that Conductor never linked can't do for itself.
998
- *
999
- * Unlike every other write here this one never changes what's on screen: it
1000
- * right-clicks the workspace's *row* (AXShowMenu) and works the menu, so the
1001
- * workspace you were reading stays open. It does need the row to be rendered, and
1002
- * a collapsed sidebar section renders none — so the script opens the folded
1003
- * sections itself, looks again, and folds back exactly the ones it opened. That
1004
- * costs a second sidebar scan, which is affordable only because that scan reads
1005
- * every row's name in two Apple events rather than one per row (15s → ~1s on a
1006
- * 50-workspace sidebar; see findSidebarRow). Measured end to end on 2026-09-01:
1007
- * 8.2s through a folded section, 5.4s through an open one. The budget is 35s
1008
- * anyway, because sidebarRowsAndNames falls back to the per-row reads when the
1009
- * list no longer matches its shape, and that path pays the old 15s twice.
1010
- */
1011
- export async function setWorkspaceStatus(workspace, status) {
1012
- const label = WORKSPACE_STATUS_LABELS[status];
1013
- if (!label)
1014
- return { ok: false, strategy: 'applescript', error: `unknown status ${status}` };
1015
- const script = `
1016
- ${CONDUCTOR_HANDLERS}
1017
-
1018
- my activateConductor()
1019
- my setWorkspaceStatus()
1020
- return "ok"`.trim();
1021
- try {
1022
- await withTargetEnvironment({ workspace, sessionId: null }, targetEnvironment => uiTurn(() => exec('osascript', ['-e', script], {
1023
- env: {
1024
- ...process.env,
1025
- ...targetEnvironment,
1026
- RELAY_SET_STATUS: label
1027
- },
1028
- timeout: 35000
1029
- })));
1030
- return { ok: true, strategy: 'applescript' };
1031
- }
1032
- catch (err) {
1033
- return { ok: false, strategy: 'applescript', error: osaError(err) };
1034
- }
1035
- }
1036
- const DEFAULT_MODEL_LINE = '__CONDUCTOR_DEFAULT_MODEL__\t';
1037
- /** Turn listModels' tagged line protocol into the live picker state. Exported for its parser tests. */
1038
- export function parseModelMenuOutput(stdout) {
1039
- let defaultModel;
1040
- const models = [];
1041
- for (const raw of stdout.split('\n')) {
1042
- const line = raw.trim();
1043
- if (!line)
1044
- continue;
1045
- if (line.startsWith(DEFAULT_MODEL_LINE)) {
1046
- defaultModel = modelPickerLabel(line.slice(DEFAULT_MODEL_LINE.length).trim()) || undefined;
1047
- continue;
1048
- }
1049
- models.push(modelPickerLabel(line));
1050
- }
1051
- return { models: [...new Set(models.filter(Boolean))], defaultModel };
1052
- }
1053
- /** The model labels Conductor is currently offering, plus its starred default. */
1054
- export async function listAgentModels(target) {
1055
- const script = `
1056
- ${CONDUCTOR_HANDLERS}
1057
-
1058
- my activateConductor()
1059
- my focusWorkspace()
1060
- my selectChatTab()
1061
- return my listModels()`.trim();
1062
- try {
1063
- const { stdout } = await withTargetEnvironment(target, targetEnvironment => uiTurn(() => exec('osascript', ['-e', script], {
1064
- env: { ...process.env, ...targetEnvironment },
1065
- timeout: SEND_ATTEMPT_MS
1066
- })));
1067
- return { ok: true, ...parseModelMenuOutput(stdout) };
1068
- }
1069
- catch (err) {
1070
- return { ok: false, error: osaError(err) };
1071
- }
1072
- }
1073
- /**
1074
- * Create a *new workspace*, optionally with a first prompt, via Conductor's
1075
- * deep-link scheme (conductor.build/docs/reference/deep-links).
1076
- *
1077
- * This is the one write here that touches no UI at all: no Accessibility, no
1078
- * keystrokes, no focus dependency — macOS hands the URL to Conductor and it
1079
- * creates the worktree. Nothing to rebreak on an update, unlike every other
1080
- * path in this file.
1081
- *
1082
- * Three things the scheme dictates:
1083
- * - Parameters sit *flat* after the scheme (`conductor://prompt=…&path=…`), not
1084
- * behind a `?`, and every value must be URL-encoded — which is also what stops
1085
- * a prompt containing `&path=` from redirecting the workspace to another repo.
1086
- * - **An unmatched (or absent) `path` silently falls back to the first repo**, so
1087
- * the caller resolves a real `root_path` first rather than trusting a name.
1088
- * - **`prompt` is optional**: a bare `conductor://path=…` opens an empty
1089
- * workspace, same as Conductor's own New workspace. That form isn't in the
1090
- * docs (every documented route carries a prompt) but is verified against the
1091
- * live app — so if it ever stops working, this is the line to suspect.
1092
- *
1093
- * The link is fire-and-forget: it reports that Conductor was *handed* the URL,
1094
- * never that a workspace appeared. The caller watches the DB for that.
1095
- */
1096
- export async function createWorkspace(prompt, repoPath) {
1097
- if (!prompt.trim() && !repoPath) {
1098
- return { ok: false, strategy: 'deeplink', error: 'a new workspace needs a repo or a first prompt' };
1099
- }
1100
- const query = [
1101
- prompt.trim() ? `prompt=${encodeURIComponent(prompt)}` : '',
1102
- repoPath ? `path=${encodeURIComponent(repoPath)}` : ''
1103
- ]
1104
- .filter(Boolean)
1105
- .join('&');
1106
- try {
1107
- // Serialized with the AX writes: creating a workspace pulls Conductor forward and
1108
- // switches which one is showing, which is precisely what a concurrent send assumes.
1109
- await uiTurn(() => exec('open', [`${CONDUCTOR_SCHEME}://${query}`], { timeout: 15000 }));
1110
- return { ok: true, strategy: 'deeplink' };
1111
- }
1112
- catch (err) {
1113
- return { ok: false, strategy: 'deeplink', error: osaError(err) };
1114
- }
1115
- }
1116
- /**
1117
- * Open a new chat in the target workspace — Conductor's "New chat, same files"
1118
- * (Cmd+T). Focuses the workspace first (its own link, see `workspaceLink`), then
1119
- * Cmd+T; the caller detects the freshly-created session id from the DB.
1120
- *
1121
- * The pane is asserted before the keystroke for the same reason a send asserts before
1122
- * typing: `focusWorkspace` confirms every route it takes except its last one, the
1123
- * palette, and Cmd+T against an unconfirmed pane opens a tab in someone else's
1124
- * workspace. Nothing catches that afterwards — the caller looks for the new session in
1125
- * *this* workspace's tab list, so a stray tab reads as "the id could not be read back"
1126
- * while sitting in a conversation nobody asked to change.
1127
- *
1128
- * Cmd+L ("Focus chat input") goes first for the reason `cancelAgent` does the same: a
1129
- * keystroke lands wherever focus is, and a focused terminal panel swallows this one.
1130
- * Measured live before the fix — the run reported success, `sessions` gained nothing,
1131
- * and `terminal_sessions` gained a row in this very workspace at the second the chord
1132
- * was sent. Both chords are Conductor's own, confirmed against its Cmd+/ dialog.
1133
- */
1134
- export async function newChat(workspace) {
1135
- if (!focusQuery(workspace))
1136
- return { ok: false, strategy: 'applescript', error: 'workspace has no branch to focus' };
1137
- const script = `
1138
- ${CONDUCTOR_HANDLERS}
1139
-
1140
- my activateConductor()
1141
- my focusWorkspace()
1142
- set strips to my tabGroups()
1143
- if (count of strips) is 0 then error "couldn't find the chat pane to open a tab in"
1144
- my assertWorkspace(item 1 of strips)
1145
- tell application "System Events"
1146
- keystroke "l" using {command down}
1147
- delay 0.2
1148
- keystroke "t" using {command down}
1149
- end tell`.trim();
1150
- try {
1151
- // Shares the focus path with a send, so it needs the same ceiling: 15s was under
1152
- // the cost of activating a cold Conductor and finding the row on its own.
1153
- await withTargetEnvironment({ workspace, sessionId: null }, targetEnvironment => uiTurn(() => exec('osascript', ['-e', script], {
1154
- env: { ...process.env, ...targetEnvironment },
1155
- timeout: SEND_ATTEMPT_MS
1156
- })));
1157
- return { ok: true, strategy: 'applescript' };
1158
- }
1159
- catch (err) {
1160
- return { ok: false, strategy: 'applescript', error: osaError(err) };
1161
- }
1162
- }
1163
- /**
1164
- * Start one exact workspace Run task, or stop the running one, through Conductor's toolbar.
1165
- *
1166
- * The task stays owned by Conductor — it appears in the Run panel, inherits the
1167
- * repository's run mode and environment, and Conductor performs its normal
1168
- * process-group shutdown. A named start selects that item from Conductor's own
1169
- * Run menu; an unnamed legacy start proceeds only when the live menu contains one
1170
- * task. Every path focuses and asserts the target workspace before touching it.
1171
- */
1172
- export async function setRunTask(workspace, running, runTaskName) {
1173
- if (!focusQuery(workspace))
1174
- return { ok: false, error: 'workspace has no branch to focus' };
1175
- const script = `
1176
- ${CONDUCTOR_HANDLERS}
1177
-
1178
- set wantRunning to (system attribute "RELAY_RUN_WANTED") is "1"
1179
- set wantedTask to do shell script "cat " & quoted form of (system attribute "RELAY_RUN_TASK_FILE")
1180
- return my setRunTask(wantRunning, wantedTask)`.trim();
1181
- const os = await import('node:os');
1182
- const fs = await import('node:fs/promises');
1183
- const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'relay-run-task-'));
1184
- const taskFile = path.join(directory, 'name');
1185
- try {
1186
- await fs.writeFile(taskFile, runTaskName ?? '', 'utf8');
1187
- const { stdout } = await withTargetEnvironment({ workspace, sessionId: workspace.active_session_id }, targetEnvironment => uiTurn(() => exec('osascript', ['-e', script], {
1188
- env: {
1189
- ...process.env,
1190
- ...targetEnvironment,
1191
- RELAY_RUN_WANTED: running ? '1' : '0',
1192
- RELAY_RUN_TASK_FILE: taskFile
1193
- },
1194
- timeout: SEND_ATTEMPT_MS
1195
- })));
1196
- const [state, task, changed, rawPorts = '', rawPreviewUrls = ''] = stdout.trim().split('\t');
1197
- if (state !== 'running' && state !== 'stopped')
1198
- throw new Error(`unexpected Run state: ${state || 'empty'}`);
1199
- const ports = rawPorts
1200
- .split(',')
1201
- .map(Number)
1202
- .filter(port => Number.isInteger(port) && port > 0 && port <= 65535);
1203
- const previewUrls = rawPreviewUrls
1204
- .split('\x1e')
1205
- .map(url => url.trim())
1206
- .filter(Boolean);
1207
- return { ok: true, state, task, changed: changed === 'true', previewUrls, ports };
1208
- }
1209
- catch (err) {
1210
- return { ok: false, error: osaError(err, 'Conductor took too long to change the Run task') };
1211
- }
1212
- finally {
1213
- await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined);
1214
- }
1215
- }
1216
- export function pickActuator(strategy) {
1217
- return strategy === 'sidecar' ? new SidecarActuator() : new AppleScriptActuator();
1218
- }
1219
- /** Effective actuator description for the UI, factoring in runtime availability. */
1220
- export async function describeActuator(actuator) {
1221
- const available = actuator.available ? await actuator.available().catch(() => false) : true;
1222
- return { name: actuator.name, caveat: actuator.caveat, precise: actuator.precise, available };
1223
- }