conductor-remote 1.34.3 → 1.35.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/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-Coq6xLXi.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-B6uWo65L.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 c=e=>i(e,o),t={module:{uri:o},exports:l,require:c};s[o]=Promise.all(n.map(e=>t[e]||c(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:"7f1d085ec2fac185f0af409edd3d81c5"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-Coq6xLXi.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:"9ec60d7e8ea0b3e08851fb81b5a4d2de"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-B6uWo65L.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\//]}))});
@@ -73,10 +73,17 @@ on windowProbe()
73
73
  -- count is -1 when macOS refused the read at all. Everything that wants to
74
74
  -- know about windows goes through here, because "returned 0" and "refused to
75
75
  -- answer" are different facts and only this handler still has both.
76
+ -- The timeout is load-bearing: a wedged Conductor (seen live after a restart
77
+ -- collided with a relaunch) answers deep links but hangs every AX read, and
78
+ -- without a cap this call blocks until the node-side ceiling kills the whole
79
+ -- run as "took too long" — 28s of silence instead of a named cause. 6s turns
80
+ -- that into error -1712, which refusalReason maps to words.
76
81
  try
77
- tell application "System Events" to tell process "Conductor"
78
- set winCount to (count of windows)
79
- end tell
82
+ with timeout of 6 seconds
83
+ tell application "System Events" to tell process "Conductor"
84
+ set winCount to (count of windows)
85
+ end tell
86
+ end timeout
80
87
  return {winCount, 0, ""}
81
88
  on error errText number errNum
82
89
  return {-1, errNum, errText}
@@ -99,6 +106,7 @@ on refusalReason(probe)
99
106
  set errText to item 3 of probe
100
107
  if errText contains "assistive access" then return "the relay is not trusted for Accessibility - grant it to the node binary running the relay in System Settings > Privacy & Security > Accessibility, then restart the relay (upgrading node silently revokes it). macOS said: " & errText
101
108
  if errNum is -1743 or errText contains "Not authorized" then return "macOS blocked the relay from controlling the UI - grant Automation permission to the node binary running the relay in System Settings > Privacy & Security > Automation. macOS said: " & errText
109
+ if errNum is -1712 then return "Conductor stopped answering UI reads (the Accessibility query timed out) - the app looks wedged even though it may still process deep links. Force-quit Conductor on your Mac and open it again; if it repeats with a healthy Conductor, System Events is stuck - run: killall 'System Events'"
102
110
  return ""
103
111
  end refusalReason
104
112
 
@@ -143,14 +151,62 @@ on menuTitles()
143
151
  end try
144
152
  end menuTitles
145
153
 
154
+ on serverWindowCount()
155
+ -- Second opinion from the window server, because the Accessibility tree only
156
+ -- shows windows on the *current* Space: a full-screen Conductor (which owns its
157
+ -- own Space) or a Mac sitting at the lock screen reads as "no window" from AX
158
+ -- while the window very much exists — and acting on the AX answer alone once
159
+ -- quit a Conductor whose window the user had left open all along.
160
+ -- CGWindowListCopyWindowInfo(kCGWindowListOptionAll) sees every Space; owner
161
+ -- name and layer are readable without the Screen Recording grant (only window
162
+ -- *titles* are gated). Layer 0 keeps it to real windows. -1 = the probe itself
163
+ -- failed, which callers must treat as "no opinion", never as "no window".
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"
172
+ try
173
+ return (do shell script "osascript -l JavaScript -e " & quoted form of jxa) as integer
174
+ on error
175
+ return -1
176
+ end try
177
+ end serverWindowCount
178
+
179
+ on screenLocked()
180
+ -- "locked" / "unlocked" / "unknown". CGSessionCopyCurrentDictionary carries
181
+ -- CGSSessionScreenIsLocked = 1 while the lock screen is up and omits the key
182
+ -- entirely when it is not — same stdlib JXA channel as serverWindowCount.
183
+ -- This matters because the lock screen is the product's home ground: sends
184
+ -- come from a phone precisely when nobody is at the Mac, so "is the lock up"
185
+ -- is the difference between a state a restart can fix and one it provably
186
+ -- makes worse (see the restart gate in activateConductor).
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'"
193
+ try
194
+ return do shell script "osascript -l JavaScript -e " & quoted form of jxa
195
+ on error
196
+ return "unknown"
197
+ end try
198
+ end screenLocked
199
+
146
200
  on windowEvidence()
147
201
  -- Zero windows is a dead end without knowing what the AX tree *does* show, and
148
202
  -- guessing the next thing to press has already cost a round trip (there is no
149
203
  -- "New Window" item — Conductor is single-window). So the error carries the
150
- -- evidence instead: who macOS thinks Conductor is, and what its menus offer.
204
+ -- evidence instead: who macOS thinks Conductor is, what its menus offer, how
205
+ -- many windows the window server counts across all Spaces (AX only sees the
206
+ -- current one), and whether the lock screen is up.
151
207
  set procs to my processWindowCounts()
152
208
  set titles to my menuTitles()
153
- set ev to ""
209
+ set ev to " [window server: " & my serverWindowCount() & "; screen: " & my screenLocked() & "]"
154
210
  if (count of procs) > 0 then set ev to ev & " [processes: " & my joinList(procs, ", ") & "]"
155
211
  if (count of titles) > 0 then set ev to ev & " [menus: " & my joinList(titles, ", ") & "]"
156
212
  return ev
@@ -253,6 +309,21 @@ on activateConductor()
253
309
  set firstProbe to my windowProbe()
254
310
  set refusal to my refusalReason(firstProbe)
255
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
256
327
  try
257
328
  tell application "Conductor" to activate
258
329
  on error errText
@@ -282,6 +353,22 @@ on activateConductor()
282
353
  -- used to drop it, which made a repeating no-window failure undebuggable
283
354
  -- from the log alone.
284
355
  set ev to my windowEvidence()
356
+ set lockState to my screenLocked()
357
+ -- AX saying "no window" is not the same as there being no window: full
358
+ -- screen on another Space and the lock screen both hide a live window from
359
+ -- AX, and restarting then destroys a window the user left open (measured
360
+ -- live: exactly that loop wedged Conductor for a whole evening). Only the
361
+ -- window server can tell the difference, so a window it CAN see blocks the
362
+ -- restart — this is a human's problem, said in words, not the relay's.
363
+ if my serverWindowCount() > 0 then
364
+ if lockState is "locked" then error "The Mac is locked - Conductor's window is open behind the lock screen, where the relay can't reach it. Unlock the Mac and send again." & ev
365
+ error "Conductor's window exists but is not reachable - it is probably full screen on another Space. Take Conductor out of full screen, then send again." & ev
366
+ end if
367
+ -- No window anywhere and the lock screen up: don't restart. Every relaunch
368
+ -- fired behind the lock screen in the live incident came up windowless, and
369
+ -- the last one came up wedged — answering deep links, hanging every AX
370
+ -- read. A restart here is not a lever, it is how the wreckage happened.
371
+ if lockState is "locked" then error "The Mac is locked and Conductor has no window. Relaunching behind the lock screen is what leaves it wedged, so the relay won't - unlock the Mac and send again." & ev
285
372
  set restartError to my restartConductor()
286
373
  if restartError is not "" then error restartError & ev
287
374
  error "Conductor was running with no window and ignored both reopen and a Dock click, so the relay restarted it. Give it a few seconds and send again." & ev
@@ -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