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