conductor-remote 1.34.4 → 1.35.1

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/dist/index.html CHANGED
@@ -13,7 +13,7 @@
13
13
  <title>Conductor Remote</title>
14
14
  <!-- Runs before the module bundle so it can catch a stale shell that fails to boot. -->
15
15
  <script src="/self-heal.js"></script>
16
- <script type="module" crossorigin src="/assets/index-CUGYKt7R.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-Bdv4R3lH.js"></script>
17
17
  <link rel="stylesheet" crossorigin href="/assets/index--7Dp2aq8.css">
18
18
  <link rel="manifest" href="/manifest.webmanifest"></head>
19
19
  <body>
package/dist/sw.js CHANGED
@@ -1 +1 @@
1
- if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(s[o])return;let l={};const t=e=>i(e,o),c={module:{uri:o},exports:l,require:t};s[o]=Promise.all(n.map(e=>c[e]||t(e))).then(e=>(r(...e),l))}}define(["./workbox-9c191d2f"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"890f6a70e97d144f4d025254e90892a1"},{url:"index.html",revision:"aa7c74f617001021bcfa69ef22c8a2b4"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-CUGYKt7R.js",revision:null},{url:"assets/index--7Dp2aq8.css",revision:null},{url:"apple-touch-icon.png",revision:"2b9301416b880d45d4bb655f2600d1f2"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
1
+ if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(s[o])return;let l={};const t=e=>i(e,o),c={module:{uri:o},exports:l,require:t};s[o]=Promise.all(n.map(e=>c[e]||t(e))).then(e=>(r(...e),l))}}define(["./workbox-9c191d2f"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"890f6a70e97d144f4d025254e90892a1"},{url:"index.html",revision:"fe03af38dc071d0c2681349d90f1876d"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-Bdv4R3lH.js",revision:null},{url:"assets/index--7Dp2aq8.css",revision:null},{url:"apple-touch-icon.png",revision:"2b9301416b880d45d4bb655f2600d1f2"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
@@ -161,7 +161,14 @@ on serverWindowCount()
161
161
  -- name and layer are readable without the Screen Recording grant (only window
162
162
  -- *titles* are gated). Layer 0 keeps it to real windows. -1 = the probe itself
163
163
  -- failed, which callers must treat as "no opinion", never as "no window".
164
- set jxa to "ObjC.import('CoreGraphics'); const w = ObjC.deepUnwrap($.CFBridgingRelease($.CGWindowListCopyWindowInfo(0, 0))) || []; w.filter(x => x.kCGWindowOwnerName === 'Conductor' && x.kCGWindowLayer === 0).length"
164
+ -- The bindFunction is the load-bearing part, twice over: as shipped this read
165
+ -- "$.CFBridgingRelease(...)", which is an *inline*, not a symbol, and calling it
166
+ -- through JXA segfaults the osascript child (measured: exit 139) — so this probe
167
+ -- returned -1 on every call and the veto it feeds never once fired. And without
168
+ -- the rebind, JXA types the CF return as a bare pointer that deepUnwrap answers
169
+ -- with undefined — same wrong verdict, silently. Rebinding the function to
170
+ -- return 'id' makes the array real (verified live: 12 windows, behind a lock).
171
+ set jxa to "ObjC.import('CoreGraphics'); ObjC.bindFunction('CGWindowListCopyWindowInfo', ['id', ['unsigned int', 'unsigned int']]); const w = ObjC.deepUnwrap($.CGWindowListCopyWindowInfo(0, 0)) || []; w.filter(x => x.kCGWindowOwnerName === 'Conductor' && x.kCGWindowLayer === 0).length"
165
172
  try
166
173
  return (do shell script "osascript -l JavaScript -e " & quoted form of jxa) as integer
167
174
  on error
@@ -177,7 +184,12 @@ on screenLocked()
177
184
  -- come from a phone precisely when nobody is at the Mac, so "is the lock up"
178
185
  -- is the difference between a state a restart can fix and one it provably
179
186
  -- makes worse (see the restart gate in activateConductor).
180
- set jxa to "ObjC.import('CoreGraphics'); const d = ObjC.deepUnwrap($.CFBridgingRelease($.CGSessionCopyCurrentDictionary())) || {}; d.CGSSessionScreenIsLocked ? 'locked' : 'unlocked'"
187
+ -- Same two traps as serverWindowCount: $.CFBridgingRelease segfaults under JXA,
188
+ -- and without the rebind deepUnwrap reads the CF dictionary as undefined — which
189
+ -- the "|| {}" then turned into a confident "unlocked" on a locked Mac. The fixed
190
+ -- probe was verified against a genuinely locked screen (CGSSessionScreenIsLocked
191
+ -- true, every session key present).
192
+ set jxa to "ObjC.import('CoreGraphics'); ObjC.bindFunction('CGSessionCopyCurrentDictionary', ['id', []]); const d = ObjC.deepUnwrap($.CGSessionCopyCurrentDictionary()) || {}; d.CGSSessionScreenIsLocked ? 'locked' : 'unlocked'"
181
193
  try
182
194
  return do shell script "osascript -l JavaScript -e " & quoted form of jxa
183
195
  on error
@@ -297,6 +309,21 @@ on activateConductor()
297
309
  set firstProbe to my windowProbe()
298
310
  set refusal to my refusalReason(firstProbe)
299
311
  if refusal is not "" then error refusal
312
+ -- The lock ahead of everything else: the lock screen hides the whole session
313
+ -- from Accessibility, so a locked Mac reads "no window" from every probe below
314
+ -- and the ladder spends the patience budget, the Dock click and the evidence
315
+ -- sweeps discovering what one read already knew — measured live (2026-08-14),
316
+ -- the attempt died at the node-side ceiling as "took too long" and the phone
317
+ -- never heard the word "locked". Asking here also stops "activate" from
318
+ -- launching Conductor behind the lock screen, which is where the windowless
319
+ -- launches that wedged it came from. A probe that already shows a window
320
+ -- proves the session is unlocked (AX can't see into a locked one), so the
321
+ -- happy path pays nothing. The error stays retryable on purpose: the caller's
322
+ -- retry loop polls this cheaply until its deadline, so an unlock mid-budget
323
+ -- lands the send by itself — that is the observed recovery, not a theory.
324
+ if (item 1 of firstProbe) < 1 and my screenLocked() is "locked" then
325
+ error "The Mac is locked - the lock screen hides Conductor from the relay, so a send can't reach it. Unlock the Mac and send again." & my windowEvidence()
326
+ end if
300
327
  try
301
328
  tell application "Conductor" to activate
302
329
  on error errText
@@ -120,14 +120,21 @@ export class FirstPromptQueue {
120
120
  }
121
121
  }
122
122
  async step(entry) {
123
- if (Date.now() - entry.createdAt > MAX_AGE_MS) {
124
- return this.fail(entry, 'the workspace never finished setting up');
125
- }
123
+ // A closed gate (locked Mac) freezes the entry whole: no attempt, and no aging
124
+ // either the age cap judges the worktree's setup, and setup doesn't need the
125
+ // screen, but failing a deliverable prompt because nobody was home to unlock
126
+ // is exactly the "gave up while you were out" this queue exists to prevent.
127
+ if (this.deps.gate && !(await this.deps.gate()))
128
+ return;
126
129
  const target = this.deps.inspect(entry.workspaceId);
127
130
  // No row yet is normal right after creation, and a workspace that really is
128
- // gone falls out through the age cap above rather than being guessed at here.
129
- if (!target?.ready || !target.sessionId)
131
+ // gone falls out through the age cap below rather than being guessed at here.
132
+ if (!target?.ready || !target.sessionId) {
133
+ if (Date.now() - entry.createdAt > MAX_AGE_MS) {
134
+ return this.fail(entry, 'the workspace never finished setting up');
135
+ }
130
136
  return;
137
+ }
131
138
  // It already went — the user sent it from the Mac, where the deep link left it
132
139
  // pre-filled in the composer. Sending again would double it.
133
140
  if (target.alreadySent)
@@ -137,6 +144,12 @@ export class FirstPromptQueue {
137
144
  const result = await this.deps.send(entry.workspaceId, target.sessionId, entry.text);
138
145
  if (result.ok)
139
146
  return this.delivered(entry);
147
+ if (result.blocked) {
148
+ // The gate closed between the check above and the send: hand the attempt back.
149
+ entry.attempts -= 1;
150
+ this.save();
151
+ return;
152
+ }
140
153
  const error = result.error ?? 'the send didn’t land';
141
154
  console.warn(`[relay] first prompt for ${entry.workspaceId} failed (attempt ${entry.attempts}): ${error}`);
142
155
  if (entry.attempts >= MAX_ATTEMPTS)
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Prompts parked because the Mac's screen is locked, delivered by the relay the
3
+ * moment it unlocks.
4
+ *
5
+ * The lock screen hides the whole session's UI from Accessibility, so while it is
6
+ * up no AppleScript write can land — and the phone's own retry budget (~a minute)
7
+ * is the wrong tool for a wait that routinely lasts hours. So a send that hits
8
+ * the lock hands its prompt to this queue and returns at once: the phone shows it
9
+ * as a queued bubble in the chat (`workspace.parked_prompts` on `/api/state`),
10
+ * the relay polls the lock and delivers when it lifts, and a push notification
11
+ * says whether it landed. Same ownership story as `firstprompt.ts`: the relay is
12
+ * the only deliverer, the phone only watches and can dismiss.
13
+ *
14
+ * The same three properties hold it up:
15
+ *
16
+ * - **One owner.** The phone never re-sends a parked prompt on its own; a manual
17
+ * send of the same text clears the entry server-side (`forgetDelivered`), so
18
+ * the two paths can't double it.
19
+ * - **It survives a restart.** `autoupdate` exits to reload new code mid-wait as
20
+ * a matter of course; the JSON file brings the queue back with the process.
21
+ * - **It gives up in public.** Time spent locked is free — a weekend away is not
22
+ * a failure — but once the Mac is unlocked, `MAX_ATTEMPTS` real delivery
23
+ * failures flip the entry to `failed` *and it stays*, text and reason visible
24
+ * on the phone, one Retry from going again.
25
+ */
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ /** Lock-probe cadence while anything waits. One tiny osascript per tick, nothing UI. */
29
+ const POLL_MS = 5000;
30
+ /** Breathing room between failed unlocked sends — Conductor is often still redrawing right after login. */
31
+ const RETRY_DELAY_MS = 5000;
32
+ const MAX_ATTEMPTS = 3;
33
+ /** Failed entries the user never dismissed are still dropped eventually. */
34
+ const KEEP_FAILED_MS = 7 * 24 * 60 * 60 * 1000;
35
+ export const PARKED_REASON = 'Sends when the Mac is unlocked';
36
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
37
+ export class ParkedPromptQueue {
38
+ // Explicit field assignment, not parameter properties: the dev run type-*strips*
39
+ // rather than transforms, and parameter properties need a transform (see CLAUDE.md).
40
+ file;
41
+ deps;
42
+ entries;
43
+ pumping = false;
44
+ constructor(file, deps) {
45
+ this.file = file;
46
+ this.deps = deps;
47
+ this.entries = this.load();
48
+ }
49
+ /** Everything the phone should see, including entries that have already failed. */
50
+ list() {
51
+ return this.entries;
52
+ }
53
+ /**
54
+ * Park a prompt (idempotent per chat + text, so a retap while locked revives
55
+ * the entry instead of doubling it) and start watching for the unlock.
56
+ */
57
+ park(workspaceId, sessionId, text, agent) {
58
+ const existing = this.entries.find(e => e.sessionId === sessionId && e.text.trim() === text.trim());
59
+ if (existing) {
60
+ // A re-park is the user asking again: back to waiting with a clean slate.
61
+ existing.status = 'waiting';
62
+ existing.attempts = 0;
63
+ existing.error = undefined;
64
+ existing.agent = agent ?? existing.agent;
65
+ this.save();
66
+ void this.pump();
67
+ return existing;
68
+ }
69
+ const entry = {
70
+ workspaceId,
71
+ sessionId,
72
+ text,
73
+ agent,
74
+ status: 'waiting',
75
+ attempts: 0,
76
+ createdAt: Date.now(),
77
+ reason: PARKED_REASON
78
+ };
79
+ this.entries = [...this.entries, entry];
80
+ this.save();
81
+ void this.pump();
82
+ return entry;
83
+ }
84
+ /** Drop everything parked for a chat — the phone's Dismiss. */
85
+ forgetSession(sessionId) {
86
+ if (!this.entries.some(e => e.sessionId === sessionId))
87
+ return false;
88
+ this.entries = this.entries.filter(e => e.sessionId !== sessionId);
89
+ this.save();
90
+ return true;
91
+ }
92
+ /**
93
+ * A send of this exact text just landed by another path (the phone retried it
94
+ * by hand once the Mac was unlocked) — delivering it again would double it.
95
+ */
96
+ forgetDelivered(sessionId, text) {
97
+ const before = this.entries.length;
98
+ this.entries = this.entries.filter(e => !(e.sessionId === sessionId && e.text.trim() === text.trim()));
99
+ if (this.entries.length !== before)
100
+ this.save();
101
+ }
102
+ /** Resume watching anything left over from a previous process. */
103
+ start() {
104
+ const waiting = this.entries.filter(e => e.status === 'waiting').length;
105
+ if (waiting)
106
+ console.info(`[relay] resuming ${waiting} prompt(s) parked for the lock screen`);
107
+ void this.pump();
108
+ }
109
+ /**
110
+ * The watch loop. While anything waits: probe the lock; locked → sleep and
111
+ * probe again (locked time costs nothing); unlocked or unknown → deliver in
112
+ * FIFO order, so two prompts parked into one chat arrive as sent. Single-flight,
113
+ * so a burst of parks can't stack loops.
114
+ */
115
+ async pump() {
116
+ if (this.pumping)
117
+ return;
118
+ this.pumping = true;
119
+ try {
120
+ while (this.entries.some(e => e.status === 'waiting')) {
121
+ if ((await this.deps.locked()) === true) {
122
+ await sleep(POLL_MS);
123
+ continue;
124
+ }
125
+ for (const entry of this.entries.filter(e => e.status === 'waiting'))
126
+ await this.step(entry);
127
+ if (this.entries.some(e => e.status === 'waiting'))
128
+ await sleep(POLL_MS);
129
+ }
130
+ }
131
+ catch (err) {
132
+ // A throw here would strand every parked prompt with no loop to retry it.
133
+ console.error('[relay] parked-prompt delivery loop crashed:', err);
134
+ }
135
+ finally {
136
+ this.pumping = false;
137
+ }
138
+ }
139
+ async step(entry) {
140
+ const result = await this.deps.deliver(entry);
141
+ if (result.ok)
142
+ return this.delivered(entry);
143
+ // The lock got there first (re-locked between the probe and the send, or the
144
+ // probe couldn't answer): that is the gate closing, not a delivery failure.
145
+ if (result.blocked)
146
+ return;
147
+ entry.attempts += 1;
148
+ this.save();
149
+ const error = result.error ?? 'the send didn’t land';
150
+ console.warn(`[relay] parked prompt for ${entry.sessionId} failed (attempt ${entry.attempts}): ${error}`);
151
+ if (entry.attempts >= MAX_ATTEMPTS)
152
+ return this.fail(entry, error);
153
+ await sleep(RETRY_DELAY_MS);
154
+ }
155
+ /** Delivered: the entry's job is done, so it stops existing — the push is the receipt. */
156
+ delivered(entry) {
157
+ this.entries = this.entries.filter(e => e !== entry);
158
+ this.save();
159
+ this.deps.notify(entry);
160
+ }
161
+ /** Given up on: kept, so the phone can show the text and the reason. */
162
+ fail(entry, error) {
163
+ entry.status = 'failed';
164
+ entry.error = error;
165
+ this.save();
166
+ this.deps.notify(entry, error);
167
+ }
168
+ load() {
169
+ let raw;
170
+ try {
171
+ raw = fs.readFileSync(this.file, 'utf8');
172
+ }
173
+ catch {
174
+ return []; // nothing parked — the common case
175
+ }
176
+ try {
177
+ const parsed = JSON.parse(raw);
178
+ if (!Array.isArray(parsed))
179
+ return [];
180
+ return parsed.filter(e => typeof e?.workspaceId === 'string' &&
181
+ typeof e.sessionId === 'string' &&
182
+ typeof e.text === 'string' &&
183
+ Date.now() - (e.createdAt ?? 0) < KEEP_FAILED_MS);
184
+ }
185
+ catch (err) {
186
+ console.warn(`[relay] ignoring unreadable ${this.file}: ${err instanceof Error ? err.message : err}`);
187
+ return [];
188
+ }
189
+ }
190
+ save() {
191
+ try {
192
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
193
+ fs.writeFileSync(this.file, JSON.stringify(this.entries, null, 2));
194
+ }
195
+ catch (err) {
196
+ // Delivery still works this run; only the restart guarantee is lost.
197
+ console.warn(`[relay] could not persist parked prompts: ${err instanceof Error ? err.message : err}`);
198
+ }
199
+ }
200
+ }
@@ -11,11 +11,12 @@ import { startFunnelWatchdog } from "./funnel-watchdog.js";
11
11
  import { workspaceDiff } from "./git.js";
12
12
  import { installLogCapture, isManaged, LOG_FILE_NAMES, logFiles, processStartedAt, recentLogs, redactSecrets, tailLogFile } from "./logbuf.js";
13
13
  import { mergePr } from "./merge.js";
14
- import { notifyDevice, pushConfig, startNotifier, subscribeDevice, unsubscribeDevice } from "./notify.js";
14
+ import { notifyAll, notifyDevice, pushConfig, startNotifier, subscribeDevice, unsubscribeDevice } from "./notify.js";
15
+ import { ParkedPromptQueue } from "./parked.js";
15
16
  import { attachPrStatus } from "./pr.js";
16
17
  import { Reads } from "./reads.js";
17
18
  import { driftWarningLines, tailscaleBin } from "./tailscale.js";
18
- import { createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, newChat, pickActuator, retryWontHelp, setAgentOptions, setRestartGuard, setWorkspaceStatus, WORKSPACE_STATUS_LABELS } from "./writes.js";
19
+ import { createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, retryWontHelp, screenLocked, setAgentOptions, setRestartGuard, setWorkspaceStatus, WORKSPACE_STATUS_LABELS } from "./writes.js";
19
20
  // Before anything that logs: from here on every console line is also kept in memory for
20
21
  // `GET /api/logs`, so the phone can read why a send failed without ssh-ing into the Mac.
21
22
  installLogCapture();
@@ -179,6 +180,11 @@ async function deliverPrompt(ws, sessionId, text, budgetMs = SEND_BUDGET_MS) {
179
180
  }
180
181
  if (retryWontHelp(last.error))
181
182
  break;
183
+ // A locked screen isn't worth the rest of the budget either — but for the
184
+ // opposite reason: the parked-prompt queue (src/parked.ts) waits it out far
185
+ // past any deadline a phone could hold open, so hand it over at once.
186
+ if (lockBlocked(last.error))
187
+ break;
182
188
  if (deadline - Date.now() < MIN_ATTEMPT_MS + CONFIRM_WINDOW_MS)
183
189
  break;
184
190
  // The phone only ever sees the outcome; why a send goes missing lives on this
@@ -216,7 +222,73 @@ const firstPrompts = new FirstPromptQueue(path.join(stateDir(), 'first-prompts.j
216
222
  if (!ws)
217
223
  return { ok: false, error: 'the workspace is gone' };
218
224
  const result = await deliverPrompt(ws, sessionId, text);
219
- return { ok: result.ok, error: result.error };
225
+ return { ok: result.ok, error: result.error, blocked: lockBlocked(result.error) };
226
+ },
227
+ // A locked Mac holds first prompts whole — no attempts spent, no aging — instead
228
+ // of burning all three sends into a lock screen nobody is there to see.
229
+ gate: async () => (await screenLocked()) !== true
230
+ });
231
+ /**
232
+ * Apply staged agent settings to a chat — the shared half of `POST …/agent` and
233
+ * of a send that carries a patch. The `fast` translation lives here because the
234
+ * UI button only *toggles*: the DB says whether the press is needed at all.
235
+ */
236
+ async function applyAgentPatch(ws, sessionId, patch) {
237
+ const located = locateChat(ws, sessionId);
238
+ if ('error' in located)
239
+ return { ok: false, error: located.error };
240
+ const opts = {
241
+ effort: patch.effort,
242
+ plan: patch.plan,
243
+ model: patch.model,
244
+ toggleFast: patch.fast === undefined ? false : patch.fast !== Boolean(located.session?.fast_mode)
245
+ };
246
+ const result = await setAgentOptions({ workspace: ws, sessionId, tab: located.tab }, opts);
247
+ if (!result.ok)
248
+ return { ok: false, error: result.error };
249
+ if (!(await confirmAgentOptions(ws, sessionId, opts))) {
250
+ return { ok: false, error: 'Conductor didn’t record the change — it may have been asleep. Try again.' };
251
+ }
252
+ return { ok: true };
253
+ }
254
+ /** What the phone is told when its prompt is parked instead of failed. */
255
+ const PARKED_ERROR = 'The Mac is locked — the relay parked the prompt and will send it when the Mac is unlocked.';
256
+ /**
257
+ * Prompts that hit the lock screen, owned by this process until the Mac unlocks
258
+ * (see parked.ts for why the phone can't wait this out itself).
259
+ */
260
+ const parkedPrompts = new ParkedPromptQueue(path.join(stateDir(), 'parked-prompts.json'), {
261
+ locked: screenLocked,
262
+ deliver: async (entry) => {
263
+ const ws = reads.getWorkspace(entry.workspaceId);
264
+ if (!ws)
265
+ return { ok: false, error: 'the workspace is gone' };
266
+ // Settings first, prompt only if they stuck — the same order and the same
267
+ // fail-closed rule as the phone's own send (running the prompt on the model
268
+ // the user moved away from is the mistake this exists to prevent). A re-run
269
+ // after a failed prompt re-applies harmlessly: every control is read before
270
+ // it is pressed, so an already-correct value presses nothing.
271
+ if (entry.agent) {
272
+ const applied = await applyAgentPatch(ws, entry.sessionId, entry.agent);
273
+ if (!applied.ok)
274
+ return { ok: false, error: applied.error, blocked: lockBlocked(applied.error) };
275
+ }
276
+ const result = await deliverPrompt(ws, entry.sessionId, entry.text);
277
+ return { ok: result.ok, error: result.error, blocked: lockBlocked(result.error) };
278
+ },
279
+ notify: (entry, error) => {
280
+ const ws = reads.getWorkspace(entry.workspaceId);
281
+ const title = ws?.workspace_name ?? ws?.pr_title ?? ws?.branch ?? 'Conductor';
282
+ const preview = entry.text.length > 140 ? `${entry.text.slice(0, 140).trimEnd()}…` : entry.text;
283
+ void notifyAll({
284
+ title,
285
+ body: error ? `Parked prompt failed: ${error}` : `Sent after unlock: ${preview}`,
286
+ // Per chat, so a second parked prompt replaces the first's notification.
287
+ tag: `parked-${entry.sessionId}`,
288
+ url: `/w/${entry.workspaceId}`,
289
+ kind: error ? 'error' : 'done',
290
+ ts: Date.now()
291
+ });
220
292
  }
221
293
  });
222
294
  const MIME = {
@@ -327,8 +399,15 @@ const server = http.createServer(async (req, res) => {
327
399
  attachPrStatus(workspaces); // colours pr_status from cache; refreshes stale entries in the background
328
400
  // An undelivered first prompt rides along with its workspace: the phone renders it
329
401
  // in that chat rather than tracking delivery itself (see src/firstprompt.ts).
330
- for (const ws of workspaces)
402
+ // Prompts parked for the lock screen ride the same way, one list per workspace,
403
+ // each entry naming its chat (src/parked.ts).
404
+ const parked = parkedPrompts.list();
405
+ for (const ws of workspaces) {
331
406
  ws.pending_prompt = firstPrompts.get(ws.id);
407
+ const mine = parked.filter(p => p.workspaceId === ws.id);
408
+ if (mine.length)
409
+ ws.parked_prompts = mine;
410
+ }
332
411
  return json(req, res, 200, {
333
412
  workspaces,
334
413
  actuator: await describeActuator(actuator),
@@ -588,30 +667,14 @@ const server = http.createServer(async (req, res) => {
588
667
  : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
589
668
  if (!ws)
590
669
  return json(req, res, 404, { error: 'workspace for session not found' });
591
- const located = locateChat(ws, sessionId);
592
- if ('error' in located)
593
- return json(req, res, 409, { error: located.error });
594
- // Fast mode exposes no readable state in the UI, so the DB decides whether
595
- // the button actually needs pressing — pressing blindly would toggle it off.
596
- const opts = {
597
- effort: body.effort,
598
- plan: body.plan,
599
- model: body.model,
600
- toggleFast: body.fast === undefined ? false : body.fast !== Boolean(located.session?.fast_mode)
601
- };
602
- const result = await setAgentOptions({ workspace: ws, sessionId, tab: located.tab }, opts);
603
- if (!result.ok)
604
- return json(req, res, 502, result);
605
- if (!(await confirmAgentOptions(ws, sessionId, opts))) {
606
- return json(req, res, 502, {
607
- ok: false,
608
- strategy: result.strategy,
609
- error: 'Conductor didn’t record the change — it may have been asleep. Try again.'
610
- });
611
- }
670
+ const applied = await applyAgentPatch(ws, sessionId, body);
671
+ if (!applied.ok)
672
+ return json(req, res, 502, { ok: false, strategy: actuator.name, error: applied.error });
612
673
  return json(req, res, 200, { ok: true, session: reads.listSessions(ws.id).find(s => s.id === sessionId) });
613
674
  }
614
- // POST /api/sessions/:id/prompt { text }
675
+ // POST /api/sessions/:id/prompt { text, agent? } — agent is the phone's staged
676
+ // settings patch, applied before the prompt so the two can't come apart (and so
677
+ // both park together when the Mac turns out to be locked).
615
678
  m = pathname.match(/^\/api\/sessions\/([^/]+)\/prompt$/);
616
679
  if (req.method === 'POST' && m) {
617
680
  const sessionId = decodeURIComponent(m[1]);
@@ -624,14 +687,46 @@ const server = http.createServer(async (req, res) => {
624
687
  : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
625
688
  if (!ws)
626
689
  return json(req, res, 404, { error: 'workspace for session not found' });
690
+ // One deadline for the whole request: settings eat into the send's budget
691
+ // rather than extending it past what the phone said it would wait.
692
+ const deadline = Date.now() + sendBudget(req);
693
+ const agent = body.agent && Object.keys(body.agent).length ? body.agent : undefined;
694
+ if (agent?.effort && !EFFORT_LABELS[agent.effort]) {
695
+ return json(req, res, 400, { error: `effort must be one of ${Object.keys(EFFORT_LABELS).join(', ')}` });
696
+ }
697
+ if (agent) {
698
+ const applied = await applyAgentPatch(ws, sessionId, agent);
699
+ if (!applied.ok) {
700
+ if (lockBlocked(applied.error)) {
701
+ const queued = parkedPrompts.park(ws.id, sessionId, text, agent);
702
+ return json(req, res, 202, {
703
+ ok: false,
704
+ parked: true,
705
+ queued,
706
+ strategy: actuator.name,
707
+ error: PARKED_ERROR
708
+ });
709
+ }
710
+ return json(req, res, 502, { ok: false, strategy: actuator.name, error: applied.error });
711
+ }
712
+ }
627
713
  // Retries live inside deliverPrompt, confirmed against the transcript each time,
628
714
  // and inside the deadline this phone told us it would wait.
629
- const result = await deliverPrompt(ws, sessionId, text, sendBudget(req));
630
- // Whatever the queue was still holding for this workspace has now been said by
631
- // hand including a failed entry the user retried from the chat.
632
- if (result.ok)
715
+ const result = await deliverPrompt(ws, sessionId, text, deadline - Date.now());
716
+ if (result.ok) {
717
+ // Whatever a queue was still holding has now been said by hand — the first
718
+ // prompt (including a failed entry retried from the chat), and any parked
719
+ // copy of this exact text, which delivering again would double.
633
720
  firstPrompts.forget(ws.id);
634
- return json(req, res, result.ok ? 200 : 502, result);
721
+ parkedPrompts.forgetDelivered(sessionId, text);
722
+ return json(req, res, 200, result);
723
+ }
724
+ if (lockBlocked(result.error)) {
725
+ // Settings (if any) already stuck, so the entry parks without them.
726
+ const queued = parkedPrompts.park(ws.id, sessionId, text);
727
+ return json(req, res, 202, { ok: false, parked: true, queued, strategy: result.strategy, error: PARKED_ERROR });
728
+ }
729
+ return json(req, res, 502, result);
635
730
  }
636
731
  // DELETE /api/workspaces/:id/prompt — dismiss an undelivered first prompt
637
732
  m = pathname.match(/^\/api\/workspaces\/([^/]+)\/prompt$/);
@@ -641,6 +736,14 @@ const server = http.createServer(async (req, res) => {
641
736
  return json(req, res, 404, { error: 'no pending prompt' });
642
737
  return json(req, res, 200, { ok: true });
643
738
  }
739
+ // DELETE /api/sessions/:id/prompt — dismiss whatever is parked for this chat
740
+ m = pathname.match(/^\/api\/sessions\/([^/]+)\/prompt$/);
741
+ if (req.method === 'DELETE' && m) {
742
+ const sessionId = decodeURIComponent(m[1]);
743
+ if (!parkedPrompts.forgetSession(sessionId))
744
+ return json(req, res, 404, { error: 'no parked prompt' });
745
+ return json(req, res, 200, { ok: true });
746
+ }
644
747
  return json(req, res, 404, { error: 'no route', pathname });
645
748
  }
646
749
  catch (err) {
@@ -671,6 +774,8 @@ server.listen(cfg.port, cfg.host, () => {
671
774
  // Pick up any first prompt the previous process was still holding — an auto-update
672
775
  // restart lands mid-setup often enough that this is the normal path, not a rare one.
673
776
  firstPrompts.start();
777
+ // Same for prompts parked behind the lock screen — a lock outlives relay restarts.
778
+ parkedPrompts.start();
674
779
  // Keep the managed global daemon current — no-ops for dev checkouts / unmanaged runs (see autoupdate.ts).
675
780
  startAutoUpdate();
676
781
  // Keep the phone's public URL reachable — re-registers Funnel when its ingress goes stale after a
@@ -80,6 +80,44 @@ const TERMINAL_ERRORS = [
80
80
  export function retryWontHelp(error) {
81
81
  return error !== undefined && TERMINAL_ERRORS.some(phrase => error.includes(phrase));
82
82
  }
83
+ /**
84
+ * A send that failed because the Mac's screen is locked. Deliberately *not* in
85
+ * `TERMINAL_ERRORS`: it isn't terminal (an unlock fixes it) but it also isn't a
86
+ * warm-up cost a retry loop should burn budget on — the caller parks the prompt
87
+ * instead (src/parked.ts) and the queue delivers it when the lock lifts. Matched
88
+ * on the phrase every lock refusal in conductor.applescript starts with; the
89
+ * words are ours, so they can't drift under us the way macOS's can.
90
+ */
91
+ export function lockBlocked(error) {
92
+ return (error ?? '').includes('The Mac is locked');
93
+ }
94
+ /**
95
+ * Node's own read of the lock screen — the same CGSessionCopyCurrentDictionary
96
+ * probe `screenLocked()` in conductor.applescript makes, minus the AppleScript
97
+ * wrapper, so the parked-prompt queue can poll it without spinning up a whole
98
+ * UI script (and without needing Accessibility at all). `null` means the probe
99
+ * itself failed — callers should treat that as "try the send and let it tell
100
+ * you", not as either lock state.
101
+ */
102
+ export async function screenLocked() {
103
+ // Keep in lockstep with `screenLocked()` in conductor.applescript, traps and
104
+ // all: $.CFBridgingRelease segfaults under JXA, and without the bindFunction
105
+ // rebind deepUnwrap reads the CF dictionary as undefined — a silent "unlocked"
106
+ // on a locked Mac.
107
+ const jxa = "ObjC.import('CoreGraphics'); ObjC.bindFunction('CGSessionCopyCurrentDictionary', ['id', []]); const d = ObjC.deepUnwrap($.CGSessionCopyCurrentDictionary()) || {}; d.CGSSessionScreenIsLocked ? 'locked' : 'unlocked'";
108
+ try {
109
+ const { stdout } = await exec('osascript', ['-l', 'JavaScript', '-e', jxa], { timeout: 5_000 });
110
+ const out = stdout.trim();
111
+ if (out === 'locked')
112
+ return true;
113
+ if (out === 'unlocked')
114
+ return false;
115
+ return null;
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
83
121
  /**
84
122
  * The sidecar IPC path — the precise, per-session write. Delivers straight to
85
123
  * `sessionId` over Conductor's own dispatch socket (see sidecar.ts), so it needs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.34.4",
3
+ "version": "1.35.1",
4
4
  "type": "module",
5
5
  "packageManager": "yarn@4.15.0",
6
6
  "description": "Phone control panel for local Conductor agents. Reads ride SQLite + git; prompts ride Conductor's own dispatch path.",