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,81 @@
1
+ import path from 'node:path';
2
+ import { CONDUCTOR_HANDLERS, osaError, RESTART_ATTEMPT_MS } from "./runner.js";
3
+ import { exec, uiTurn } from "./ui-lock.js";
4
+ /**
5
+ * Press the Wi-Fi menu's row for a personal hotspot — Instant Hotspot, the same
6
+ * button a human clicks. `networksetup` can only join a network that is
7
+ * broadcasting, and a personal hotspot usually isn't; the row in Control
8
+ * Center's Wi-Fi popover is fed by Continuity over Bluetooth, and pressing it
9
+ * asks the phone to wake its hotspot. The funnel watchdog reaches for this when
10
+ * a plain join answered "Could not find network".
11
+ *
12
+ * The one UI write here that doesn't target Conductor, and it still takes a
13
+ * uiTurn: the popover steals key focus, so a palette fallback running at the
14
+ * same moment would type into it. The name travels via a temp file like the
15
+ * prompt does — same escaping-and-encoding dodge, and hotspot names ("Han
16
+ * høyes iPhone") are non-ASCII more often than prompts are. Success here means
17
+ * *pressed*, nothing more: joining takes several seconds of Bluetooth wake +
18
+ * DHCP, so the caller owns the wait, and it watches `hasDefaultRoute()` — the
19
+ * one link signal that needs no permission — not this function's word.
20
+ * Everything else — the lock check, the toggle-aware close, the already-open
21
+ * abort, the contention story — lives with the handler in src/writes/applescript/hotspot.applescript.
22
+ */
23
+ export async function joinInstantHotspot(name) {
24
+ const script = `
25
+ ${CONDUCTOR_HANDLERS}
26
+
27
+ my joinInstantHotspot()`.trim();
28
+ const os = await import('node:os');
29
+ const fs = await import('node:fs/promises');
30
+ const tmp = path.join(os.tmpdir(), `relay-hotspot-${process.pid}-${Date.now()}.txt`);
31
+ await fs.writeFile(tmp, name, 'utf8');
32
+ try {
33
+ await uiTurn(() => exec('osascript', ['-e', script], {
34
+ env: { ...process.env, RELAY_HOTSPOT_FILE: tmp },
35
+ timeout: 25_000
36
+ }));
37
+ return { ok: true };
38
+ }
39
+ catch (err) {
40
+ return { ok: false, error: osaError(err, 'the Wi-Fi menu press took too long') };
41
+ }
42
+ finally {
43
+ await fs.rm(tmp, { force: true }).catch(() => undefined);
44
+ }
45
+ }
46
+ /**
47
+ * Quit Conductor and start it again because the phone asked — the one write here
48
+ * whose subject is the app rather than anything inside it.
49
+ *
50
+ * The lever already existed and was unreachable. `activateConductor` restarts as its
51
+ * last resort, but only after a *running* Conductor has drawn no window through
52
+ * `reopen` and a Dock click, so it answers exactly one failure: a windowless app. The
53
+ * failure this is for looks healthy from every probe on that ladder — window up,
54
+ * sidebar drawing, composer taking prompts — while the agent runtime behind it has
55
+ * stopped producing anything. Measured on this Mac (2026-09-02): the last agent frame
56
+ * in `session_messages` was 20:47:44 and prompts kept landing as user rows for the
57
+ * next two and a half hours, each turn flipping `working → idle` having written
58
+ * nothing. Nothing on the read side can fix that, and "quit it on your Mac" is not
59
+ * advice a phone can act on.
60
+ *
61
+ * Two gates, and neither lives here. The **working chats** are counted from the DB by
62
+ * src/http/routes/system.ts, which refuses without `stopAgents` — quitting takes every agent mid-turn
63
+ * down with it, so that has to be said out loud, exactly as it is for archiving. The
64
+ * **lock screen** is asked by `restartApp` itself, because a relaunch fired behind it
65
+ * comes up windowless (and once, wedged). What is left for this function is the UI
66
+ * lock: a restart is not a read, and letting one land while a send is mid-flight would
67
+ * quit the app between the composer write and the Enter.
68
+ */
69
+ export async function restartConductorApp() {
70
+ const script = `
71
+ ${CONDUCTOR_HANDLERS}
72
+
73
+ return my restartApp()`.trim();
74
+ try {
75
+ await uiTurn(() => exec('osascript', ['-e', script], { env: { ...process.env }, timeout: RESTART_ATTEMPT_MS }));
76
+ return { ok: true, strategy: 'applescript' };
77
+ }
78
+ catch (err) {
79
+ return { ok: false, strategy: 'applescript', error: osaError(err, 'Conductor didn’t come back in time') };
80
+ }
81
+ }
@@ -0,0 +1,121 @@
1
+ import path from 'node:path';
2
+ /**
3
+ * The URL scheme Conductor registers, which is per *release channel*: the
4
+ * production build answers `conductor://`, the pre-release ones
5
+ * `conductor-alpha://`, `conductor-beta://`, `conductor-dev://` and friends.
6
+ * Everything else in this file addresses `application "Conductor"` by name, so
7
+ * production is the only channel the write path works against anyway — the
8
+ * override exists so a channel build needs a variable rather than a patch.
9
+ */
10
+ export const CONDUCTOR_SCHEME = process.env.RELAY_CONDUCTOR_SCHEME || 'conductor';
11
+ /**
12
+ * Conductor's own link to a workspace, and optionally to one chat inside it —
13
+ * exactly what its sidebar row menu copies under "Copy link" (Cmd+Shift+C).
14
+ *
15
+ * `conductor://workspace?id=<workspace>&session=<chat>` is the shape that works,
16
+ * and the near misses all fail *badly*, so this is the one place that builds it:
17
+ * the parameters sit behind a real `?` (unlike the create-workspace links, which
18
+ * are flat after the scheme), the workspace id is `id` rather than `workspace`,
19
+ * and `workspace` must be the URL's **host** — `conductor:///workspace/<id>`,
20
+ * with the id in the path, falls through to the flat-parameter parser and
21
+ * **creates a new workspace in the first repo**. A path-shaped link with the
22
+ * right host (`conductor://workspace/<id>`) is merely ignored.
23
+ *
24
+ * `session` is optional and names a chat by `sessions.id`, the same id the relay
25
+ * already serves; omitted, Conductor opens whichever tab that workspace had.
26
+ * Current Conductor builds also unhide a session named by this route, through
27
+ * their own session service. `restoreChat` uses that behavior and confirms the
28
+ * exact id became visible; older builds may ignore it, so opening is no receipt.
29
+ *
30
+ * The https form Conductor copies for sharing —
31
+ * `https://app.conductor.build/workspace/<id>?session=<chat>` — reaches the same
32
+ * handler, but only once macOS has decided to hand it to the app; the desktop
33
+ * build declares no associated domain, so a browser gets it first. Locally the
34
+ * scheme form is the one that always lands.
35
+ */
36
+ export function workspaceLink(workspaceId, sessionId) {
37
+ const params = new URLSearchParams({ id: workspaceId });
38
+ if (sessionId)
39
+ params.set('session', sessionId);
40
+ return `${CONDUCTOR_SCHEME}://workspace?${params.toString()}`;
41
+ }
42
+ /** Conductor's command palette matches workspaces by branch — its unique key. A
43
+ * looser query (directory name) can match a command like unarchive, so prefer
44
+ * branch and only fall back when it's absent. */
45
+ export function focusQuery(ws) {
46
+ return ws.branch || ws.workspace_name || ws.directory_name || '';
47
+ }
48
+ /**
49
+ * Every title Conductor might be showing for this workspace in the sidebar
50
+ * (its precedence: manual name → PR title → humanized branch → codename). The
51
+ * sidebar press tries each and requires a unique row; a miss just means we fall
52
+ * back to the palette, so this doesn't have to reproduce the precedence exactly.
53
+ */
54
+ function sidebarTitles(ws) {
55
+ const slug = ws.branch?.includes('/') ? ws.branch.slice(ws.branch.indexOf('/') + 1) : ws.branch;
56
+ const humanized = slug?.replace(/[-_]/g, ' ').trim();
57
+ return [
58
+ ws.workspace_name,
59
+ ws.pr_title,
60
+ humanized ? humanized[0].toUpperCase() + humanized.slice(1) : '',
61
+ ws.directory_name
62
+ ].filter((t) => Boolean(t));
63
+ }
64
+ /**
65
+ * May the actuator restart Conductor to force a window into existence?
66
+ *
67
+ * Restarting is the only lever left when a running, windowless Conductor ignores
68
+ * both `reopen` and a Dock click — it is single-window and single-instance, so
69
+ * there is nothing else to press. It is also the one step here that can destroy
70
+ * work: quitting takes any agent mid-turn down with it. "Nothing is running" is a
71
+ * fact only the read side has, so src/http/services/base.ts wires this to a DB read rather than
72
+ * src/writes/targeting.ts guessing. Unset → never restart, which is the safe default for any
73
+ * caller that hasn't opted in.
74
+ */
75
+ let restartGuard = null;
76
+ export function setRestartGuard(guard) {
77
+ restartGuard = guard;
78
+ }
79
+ function restartAllowed() {
80
+ try {
81
+ return restartGuard?.() ?? false;
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ }
87
+ /** Fixed targeting values travel in the environment; the three UI labels ride in RELAY_TARGET_FILE. */
88
+ function targetEnv(target) {
89
+ return {
90
+ RELAY_ALLOW_RESTART: restartAllowed() ? '1' : '',
91
+ RELAY_TAB_INDEX: String(target.tab?.index ?? 0),
92
+ RELAY_TAB_COUNT: String(target.tab?.count ?? 0),
93
+ RELAY_WS_BRANCH: target.workspace.branch ?? '',
94
+ RELAY_WS_REPO: target.workspace.repo_name ?? '',
95
+ RELAY_WS_LINK: workspaceLink(target.workspace.id, target.sessionId)
96
+ };
97
+ }
98
+ /**
99
+ * The target file is line-based: tab title, palette query, then sidebar-title candidates. Conductor labels
100
+ * cannot contain a newline, and `do shell script` reads this file as UTF-8 rather than MacRoman.
101
+ */
102
+ function targetLines(target) {
103
+ return [target.tab?.title ?? '', focusQuery(target.workspace), ...sidebarTitles(target.workspace)];
104
+ }
105
+ /**
106
+ * Give one UI action the three matching labels in a UTF-8 file. `system attribute` decodes text as MacRoman,
107
+ * but it safely carries the temporary file path.
108
+ */
109
+ export async function withTargetEnvironment(target, action) {
110
+ const fs = await import('node:fs/promises');
111
+ const os = await import('node:os');
112
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'relay-target-'));
113
+ const file = path.join(directory, 'text');
114
+ try {
115
+ await fs.writeFile(file, targetLines(target).join('\n'), 'utf8');
116
+ return await action({ ...targetEnv(target), RELAY_TARGET_FILE: file });
117
+ }
118
+ finally {
119
+ await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined);
120
+ }
121
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,229 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { runGatedWorkflowCommand } from "../orchestration/workflow/effect-runner.js";
5
+ const execDirect = promisify(execFile);
6
+ /** Waiting runs past this are refused rather than queued. */
7
+ const MAX_UI_QUEUE = 4;
8
+ const UI_BUSY = "Conductor's UI is busy";
9
+ const uiPriorityScope = new AsyncLocalStorage();
10
+ const uiDispatchScope = new AsyncLocalStorage();
11
+ const uiCommandGateScope = new AsyncLocalStorage();
12
+ const uiLeaseExecutionScope = new AsyncLocalStorage();
13
+ let sharedUiLeaseProvider = null;
14
+ /** Route every subsequent UI turn through a cooperating cross-process lease. */
15
+ export function configureSharedUiLeaseProvider(provider) {
16
+ sharedUiLeaseProvider = provider;
17
+ }
18
+ /**
19
+ * Mark one durable effect as dispatched immediately before its first GUI action.
20
+ * Several UI turns may be needed to configure and send one effect; the shared scope
21
+ * makes the durable transition exactly once across all of them.
22
+ */
23
+ export function withUiDispatchHook(hook, fn, actionId) {
24
+ const context = { hook, actionId, fired: false };
25
+ return uiDispatchScope.run(context, async () => {
26
+ try {
27
+ return await fn();
28
+ }
29
+ finally {
30
+ // The coordinator commits success or ambiguity before its callback settles.
31
+ // Release here so another process can never enter the window between the GUI
32
+ // returning and that durable receipt decision.
33
+ const lease = context.lease;
34
+ context.lease = undefined;
35
+ await lease?.release();
36
+ }
37
+ });
38
+ }
39
+ /**
40
+ * Route the first external command executed inside a UI turn through the private
41
+ * Workflow gate. Read-only probes before the UI turn stay in-process; later UI
42
+ * turns are already behind a durable may-execute boundary and run normally.
43
+ */
44
+ export function withGatedUiCommand(onSpawned, fn) {
45
+ if (uiCommandGateScope.getStore())
46
+ throw new Error('a gated UI command scope is already active');
47
+ return uiCommandGateScope.run({ onSpawned }, () => fn(exec));
48
+ }
49
+ function commandEnvironment(environment) {
50
+ if (!environment)
51
+ return undefined;
52
+ return Object.fromEntries(Object.entries(environment).filter((entry) => typeof entry[1] === 'string' && process.env[entry[0]] !== entry[1]));
53
+ }
54
+ /** Execute an external helper, honoring a Workflow's one-shot private command gate. */
55
+ export async function exec(command, args, options = {}) {
56
+ const gate = uiCommandGateScope.getStore();
57
+ const execution = uiLeaseExecutionScope.getStore();
58
+ if (execution && !execution.active)
59
+ throw new Error('a UI command escaped its shared lease');
60
+ if (!gate || execution?.gate !== gate)
61
+ return execDirect(command, args, options);
62
+ if (execution.commandRunning)
63
+ throw new Error('gated UI commands must execute sequentially');
64
+ // Claim synchronously so two accidentally-overlapping commands can never both
65
+ // obtain a GO descriptor from one durable effect. Sequential commands refresh
66
+ // the persisted external identity before each new GO.
67
+ execution.commandRunning = true;
68
+ try {
69
+ const result = await runGatedWorkflowCommand({
70
+ command,
71
+ args,
72
+ ...(options.cwd ? { cwd: options.cwd } : {}),
73
+ ...(options.env ? { env: commandEnvironment(options.env) } : {}),
74
+ ...(options.timeout === undefined ? {} : { timeoutMs: options.timeout }),
75
+ ...(options.maxBuffer === undefined ? {} : { maxOutputBytes: options.maxBuffer }),
76
+ ...(options.signal ? { signal: options.signal } : {}),
77
+ onSpawned: async (process) => {
78
+ // Neither commit alone releases the wrapper. GO crosses the pipe only after
79
+ // both the effect and the cross-process mutex carry this exact identity.
80
+ await gate.onSpawned(process);
81
+ await execution.lease?.markMayExecute(process);
82
+ }
83
+ });
84
+ return { stdout: result.stdout, stderr: result.stderr };
85
+ }
86
+ finally {
87
+ execution.commandRunning = false;
88
+ }
89
+ }
90
+ async function markUiDispatched(context) {
91
+ if (!context || context.fired)
92
+ return;
93
+ if (!context.firing) {
94
+ context.firing = Promise.resolve()
95
+ .then(() => context.hook())
96
+ .then(() => {
97
+ context.fired = true;
98
+ });
99
+ }
100
+ await context.firing;
101
+ }
102
+ /** Run `fn` with every UI operation it triggers marked at `priority`. */
103
+ export function withUiPriority(priority, fn) {
104
+ return uiPriorityScope.run(priority, fn);
105
+ }
106
+ export class UiBusyError extends Error {
107
+ waiting;
108
+ constructor(waiting) {
109
+ super(`${UI_BUSY} — ${waiting} operation${waiting === 1 ? '' : 's'} already queued. Try again shortly.`);
110
+ this.name = 'UiBusyError';
111
+ this.waiting = waiting;
112
+ }
113
+ }
114
+ /** Writes stringify `UiBusyError`, so background queues recognize both forms. */
115
+ export function uiBusy(error) {
116
+ return (error ?? '').startsWith(UI_BUSY);
117
+ }
118
+ let uiRunning = false;
119
+ let uiSeq = 0;
120
+ const uiWaiting = [];
121
+ /** What the UI lock is doing right now — `waiting` excludes the run in flight. */
122
+ export function uiQueueDepth() {
123
+ return { waiting: uiWaiting.length, busy: uiRunning };
124
+ }
125
+ function pumpUi() {
126
+ if (uiRunning)
127
+ return;
128
+ const next = uiWaiting.shift();
129
+ if (!next)
130
+ return;
131
+ uiRunning = true;
132
+ next.start();
133
+ }
134
+ /**
135
+ * Take the lock, run `op`, release it. Exported for `tests/writes/ui-lock.test.ts`,
136
+ * which is the only way this queue's control flow gets read by anything.
137
+ */
138
+ export function uiTurn(op) {
139
+ // High-level Workflow effects hold one cross-process lease through their durable
140
+ // receipt poll. Existing actuator helpers still call uiTurn internally; reusing
141
+ // the outer lease keeps that nesting from deadlocking or opening a correlation gap.
142
+ if (uiLeaseExecutionScope.getStore()?.active)
143
+ return Promise.resolve().then(op);
144
+ const rank = uiPriorityScope.getStore() === 'background' ? 1 : 0;
145
+ // A queued callback is eventually started from the previous owner's async chain,
146
+ // so capture the effect scope now rather than consulting AsyncLocalStorage later.
147
+ const dispatch = uiDispatchScope.getStore();
148
+ const commandGate = uiCommandGateScope.getStore();
149
+ if (uiWaiting.length >= MAX_UI_QUEUE)
150
+ return Promise.reject(new UiBusyError(uiWaiting.length));
151
+ return new Promise((resolve, reject) => {
152
+ const waiter = {
153
+ rank,
154
+ seq: uiSeq++,
155
+ start: () => {
156
+ const priority = rank === 1 ? 'background' : 'interactive';
157
+ const provider = sharedUiLeaseProvider;
158
+ // Acquire the process-shared lease only after this operation owns the local
159
+ // priority lock. The dispatch transition follows both locks and precedes the
160
+ // first instruction in `op`, so a failure before it is provably replayable.
161
+ const settled = (async () => {
162
+ let lease = null;
163
+ let releaseHere = true;
164
+ try {
165
+ lease = dispatch?.lease ?? null;
166
+ if (!lease && provider) {
167
+ lease = await provider.acquire({
168
+ priority,
169
+ ...(dispatch?.actionId ? { actionId: dispatch.actionId } : {})
170
+ });
171
+ if (dispatch)
172
+ dispatch.lease = lease;
173
+ }
174
+ releaseHere = !dispatch;
175
+ await markUiDispatched(dispatch);
176
+ // An ordinary turn becomes potentially mutating immediately before its
177
+ // operation. A Workflow's first external command instead supplies the
178
+ // blocked wrapper identity and flips this boundary from inside `exec`.
179
+ const pendingGate = commandGate;
180
+ if (!pendingGate)
181
+ await lease?.markMayExecute();
182
+ const execution = {
183
+ lease,
184
+ commandRunning: false,
185
+ active: true,
186
+ ...(pendingGate ? { gate: pendingGate } : {})
187
+ };
188
+ try {
189
+ return await uiLeaseExecutionScope.run(execution, op);
190
+ }
191
+ finally {
192
+ execution.active = false;
193
+ }
194
+ }
195
+ finally {
196
+ // A lease release participates in the operation's result. In particular, do
197
+ // not tell a caller the UI is free while another process still sees us as
198
+ // its durable owner.
199
+ if (releaseHere)
200
+ await lease?.release();
201
+ }
202
+ })();
203
+ // Release *before* resolving the caller, not after. Settling first and cleaning
204
+ // up in a chained `.then` frees the lock one microtask late, so code that awaits
205
+ // a write and then reads `uiQueueDepth()` is told a run is still in flight when
206
+ // none is — and the next turn starts a tick later than it could.
207
+ const release = () => {
208
+ uiRunning = false;
209
+ pumpUi();
210
+ };
211
+ settled.then(value => {
212
+ release();
213
+ resolve(value);
214
+ }, err => {
215
+ release();
216
+ reject(err);
217
+ });
218
+ }
219
+ };
220
+ // Stable insert: by rank, FIFO within a rank. A background run already started
221
+ // keeps the lock — this decides who is next, never who is interrupted.
222
+ const at = uiWaiting.findIndex(w => w.rank > rank);
223
+ if (at < 0)
224
+ uiWaiting.push(waiter);
225
+ else
226
+ uiWaiting.splice(at, 0, waiter);
227
+ pumpUi();
228
+ });
229
+ }
@@ -0,0 +1,233 @@
1
+ import path from 'node:path';
2
+ import { CONDUCTOR_HANDLERS, osaError, SEND_ATTEMPT_MS } from "./runner.js";
3
+ import { CONDUCTOR_SCHEME, focusQuery, withTargetEnvironment } from "./targeting.js";
4
+ import { exec, uiTurn } from "./ui-lock.js";
5
+ /**
6
+ * Put a workspace away — Conductor's own "Archive workspace" (Command-Shift-A),
7
+ * pressed on the workspace this run has just focused and asserted.
8
+ *
9
+ * The chord acts on whatever the pane is showing, so the assertion is the entire
10
+ * guard and the branch is required rather than optional, exactly as it is for
11
+ * `stopTurn`. The damage is worse here than a mis-aimed prompt: archiving deletes
12
+ * the worktree and takes any agent mid-turn down with it, so a workspace whose pane
13
+ * cannot be checked is refused instead of guessed at.
14
+ *
15
+ * `stopAgents` is what the caller has said about that second half. Conductor draws a
16
+ * confirmation ("Stop agents and archive") when the workspace still has an agent
17
+ * working, and the script presses it only with this set; without it the dialog is
18
+ * dismissed and the run fails, so a tap that meant "archive" can never quietly end
19
+ * someone else's turn. An idle workspace draws no dialog at all.
20
+ *
21
+ * Nothing is confirmed here. `workspaces.state` becoming `archived` is the receipt
22
+ * and src/http/routes/workspaces.ts waits for it, the same way the stop and the status change do.
23
+ */
24
+ export async function archiveWorkspace(workspace, stopAgents) {
25
+ if (!workspace.branch) {
26
+ return { ok: false, strategy: 'applescript', error: 'workspace has no branch to focus' };
27
+ }
28
+ const script = `
29
+ ${CONDUCTOR_HANDLERS}
30
+
31
+ my activateConductor()
32
+ my focusWorkspace()
33
+ my archiveWorkspace()
34
+ return "ok"`.trim();
35
+ try {
36
+ await withTargetEnvironment({ workspace, sessionId: null }, targetEnvironment => uiTurn(() => exec('osascript', ['-e', script], {
37
+ env: { ...process.env, ...targetEnvironment, RELAY_ARCHIVE_AGENTS: stopAgents ? '1' : '' },
38
+ timeout: SEND_ATTEMPT_MS
39
+ })));
40
+ return { ok: true, strategy: 'applescript' };
41
+ }
42
+ catch (err) {
43
+ return { ok: false, strategy: 'applescript', error: osaError(err) };
44
+ }
45
+ }
46
+ /**
47
+ * Continue a merged workspace on a new branch through Conductor's own Continue
48
+ * button. This deliberately does not perform `git checkout` itself: the native
49
+ * action also updates Conductor's workspace identity, preserves the existing
50
+ * sessions, clears the merged PR metadata, and stages its Branch continued.md
51
+ * context in the selected chat. Reproducing only the git half would leave the DB
52
+ * and UI describing the branch we just moved away from; writing the DB is forbidden.
53
+ *
54
+ * The exact chat matters even though every chat survives. Conductor attaches the
55
+ * continuation note to the selected one, so the target carries the phone's current
56
+ * session and `selectChatTab` asserts it before the button is found. The button is
57
+ * available only in Conductor's merged action bar; an absent or ambiguous control
58
+ * fails closed. src/http/routes/workspaces.ts confirms success from the resulting branch change.
59
+ */
60
+ export async function continueWorkspace(target) {
61
+ if (!target.workspace.branch) {
62
+ return { ok: false, strategy: 'applescript', error: 'workspace has no branch to focus' };
63
+ }
64
+ const script = `
65
+ ${CONDUCTOR_HANDLERS}
66
+
67
+ my activateConductor()
68
+ my focusWorkspace()
69
+ my selectChatTab()
70
+ my continueWorkspaceOnNewBranch()
71
+ return "ok"`.trim();
72
+ try {
73
+ await withTargetEnvironment(target, targetEnvironment => uiTurn(() => exec('osascript', ['-e', script], {
74
+ env: { ...process.env, ...targetEnvironment },
75
+ timeout: SEND_ATTEMPT_MS
76
+ })));
77
+ return { ok: true, strategy: 'applescript' };
78
+ }
79
+ catch (err) {
80
+ return { ok: false, strategy: 'applescript', error: osaError(err) };
81
+ }
82
+ }
83
+ /**
84
+ * The workspace statuses Conductor's sidebar groups by, mapped from the value it
85
+ * stores in `workspaces.manual_status` to the label on its own menu. `canceled`
86
+ * is on the menu but has never been written in this DB, so it's the one spelling
87
+ * here that is taken from the UI rather than confirmed against stored data — a
88
+ * mismatch surfaces as a failed confirmation naming what Conductor actually wrote.
89
+ */
90
+ export const WORKSPACE_STATUS_LABELS = {
91
+ backlog: 'Backlog',
92
+ 'in-progress': 'In progress',
93
+ 'in-review': 'In review',
94
+ done: 'Done',
95
+ canceled: 'Canceled'
96
+ };
97
+ /**
98
+ * Move a workspace between the sidebar's status groups — the thing a merged PR
99
+ * that Conductor never linked can't do for itself.
100
+ *
101
+ * Unlike every other write here this one never changes what's on screen: it
102
+ * right-clicks the workspace's *row* (AXShowMenu) and works the menu, so the
103
+ * workspace you were reading stays open. It does need the row to be rendered, and
104
+ * a collapsed sidebar section renders none — so the script opens the folded
105
+ * sections itself, looks again, and folds back exactly the ones it opened. That
106
+ * costs a second sidebar scan, which is affordable only because that scan reads
107
+ * every row's name in two Apple events rather than one per row (15s → ~1s on a
108
+ * 50-workspace sidebar; see findSidebarRow). Measured end to end on 2026-09-01:
109
+ * 8.2s through a folded section, 5.4s through an open one. The budget is 35s
110
+ * anyway, because sidebarRowsAndNames falls back to the per-row reads when the
111
+ * list no longer matches its shape, and that path pays the old 15s twice.
112
+ */
113
+ export async function setWorkspaceStatus(workspace, status) {
114
+ const label = WORKSPACE_STATUS_LABELS[status];
115
+ if (!label)
116
+ return { ok: false, strategy: 'applescript', error: `unknown status ${status}` };
117
+ const script = `
118
+ ${CONDUCTOR_HANDLERS}
119
+
120
+ my activateConductor()
121
+ my setWorkspaceStatus()
122
+ return "ok"`.trim();
123
+ try {
124
+ await withTargetEnvironment({ workspace, sessionId: null }, targetEnvironment => uiTurn(() => exec('osascript', ['-e', script], {
125
+ env: {
126
+ ...process.env,
127
+ ...targetEnvironment,
128
+ RELAY_SET_STATUS: label
129
+ },
130
+ timeout: 35000
131
+ })));
132
+ return { ok: true, strategy: 'applescript' };
133
+ }
134
+ catch (err) {
135
+ return { ok: false, strategy: 'applescript', error: osaError(err) };
136
+ }
137
+ }
138
+ /**
139
+ * Create a *new workspace*, optionally with a first prompt, via Conductor's
140
+ * deep-link scheme (conductor.build/docs/reference/deep-links).
141
+ *
142
+ * This is the one write here that touches no UI at all: no Accessibility, no
143
+ * keystrokes, no focus dependency — macOS hands the URL to Conductor and it
144
+ * creates the worktree. Nothing to rebreak on an update, unlike every other
145
+ * path in this file.
146
+ *
147
+ * Three things the scheme dictates:
148
+ * - Parameters sit *flat* after the scheme (`conductor://prompt=…&path=…`), not
149
+ * behind a `?`, and every value must be URL-encoded — which is also what stops
150
+ * a prompt containing `&path=` from redirecting the workspace to another repo.
151
+ * - **An unmatched (or absent) `path` silently falls back to the first repo**, so
152
+ * the caller resolves a real `root_path` first rather than trusting a name.
153
+ * - **`prompt` is optional**: a bare `conductor://path=…` opens an empty
154
+ * workspace, same as Conductor's own New workspace. That form isn't in the
155
+ * docs (every documented route carries a prompt) but is verified against the
156
+ * live app — so if it ever stops working, this is the line to suspect.
157
+ *
158
+ * The link is fire-and-forget: it reports that Conductor was *handed* the URL,
159
+ * never that a workspace appeared. The caller watches the DB for that.
160
+ */
161
+ export async function createWorkspace(prompt, repoPath) {
162
+ if (!prompt.trim() && !repoPath) {
163
+ return { ok: false, strategy: 'deeplink', error: 'a new workspace needs a repo or a first prompt' };
164
+ }
165
+ const query = [
166
+ prompt.trim() ? `prompt=${encodeURIComponent(prompt)}` : '',
167
+ repoPath ? `path=${encodeURIComponent(repoPath)}` : ''
168
+ ]
169
+ .filter(Boolean)
170
+ .join('&');
171
+ try {
172
+ // Serialized with the AX writes: creating a workspace pulls Conductor forward and
173
+ // switches which one is showing, which is precisely what a concurrent send assumes.
174
+ await uiTurn(() => exec('open', [`${CONDUCTOR_SCHEME}://${query}`], { timeout: 15000 }));
175
+ return { ok: true, strategy: 'deeplink' };
176
+ }
177
+ catch (err) {
178
+ return { ok: false, strategy: 'deeplink', error: osaError(err) };
179
+ }
180
+ }
181
+ /**
182
+ * Start one exact workspace Run task, or stop the running one, through Conductor's toolbar.
183
+ *
184
+ * The task stays owned by Conductor — it appears in the Run panel, inherits the
185
+ * repository's run mode and environment, and Conductor performs its normal
186
+ * process-group shutdown. A named start selects that item from Conductor's own
187
+ * Run menu; an unnamed legacy start proceeds only when the live menu contains one
188
+ * task. Every path focuses and asserts the target workspace before touching it.
189
+ */
190
+ export async function setRunTask(workspace, running, runTaskName) {
191
+ if (!focusQuery(workspace))
192
+ return { ok: false, error: 'workspace has no branch to focus' };
193
+ const script = `
194
+ ${CONDUCTOR_HANDLERS}
195
+
196
+ set wantRunning to (system attribute "RELAY_RUN_WANTED") is "1"
197
+ set wantedTask to do shell script "cat " & quoted form of (system attribute "RELAY_RUN_TASK_FILE")
198
+ return my setRunTask(wantRunning, wantedTask)`.trim();
199
+ const os = await import('node:os');
200
+ const fs = await import('node:fs/promises');
201
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'relay-run-task-'));
202
+ const taskFile = path.join(directory, 'name');
203
+ try {
204
+ await fs.writeFile(taskFile, runTaskName ?? '', 'utf8');
205
+ const { stdout } = await withTargetEnvironment({ workspace, sessionId: workspace.active_session_id }, targetEnvironment => uiTurn(() => exec('osascript', ['-e', script], {
206
+ env: {
207
+ ...process.env,
208
+ ...targetEnvironment,
209
+ RELAY_RUN_WANTED: running ? '1' : '0',
210
+ RELAY_RUN_TASK_FILE: taskFile
211
+ },
212
+ timeout: SEND_ATTEMPT_MS
213
+ })));
214
+ const [state, task, changed, rawPorts = '', rawPreviewUrls = ''] = stdout.trim().split('\t');
215
+ if (state !== 'running' && state !== 'stopped')
216
+ throw new Error(`unexpected Run state: ${state || 'empty'}`);
217
+ const ports = rawPorts
218
+ .split(',')
219
+ .map(Number)
220
+ .filter(port => Number.isInteger(port) && port > 0 && port <= 65535);
221
+ const previewUrls = rawPreviewUrls
222
+ .split('\x1e')
223
+ .map(url => url.trim())
224
+ .filter(Boolean);
225
+ return { ok: true, state, task, changed: changed === 'true', previewUrls, ports };
226
+ }
227
+ catch (err) {
228
+ return { ok: false, error: osaError(err, 'Conductor took too long to change the Run task') };
229
+ }
230
+ finally {
231
+ await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined);
232
+ }
233
+ }