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/assets/{index-Coq6xLXi.js → index-B6uWo65L.js} +10 -10
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/dist-node/src/conductor.applescript +92 -5
- package/dist-node/src/firstprompt.js +18 -5
- package/dist-node/src/parked.js +200 -0
- package/dist-node/src/server.js +136 -31
- package/dist-node/src/writes.js +38 -0
- package/package.json +1 -1
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-
|
|
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
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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,
|
|
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
|
-
|
|
124
|
-
|
|
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
|
|
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
|
+
}
|
package/dist-node/src/server.js
CHANGED
|
@@ -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
|
|
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
|
|
592
|
-
if (
|
|
593
|
-
return json(req, res,
|
|
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,
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
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
|