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