conductor-remote 1.65.0 → 1.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/dist/assets/index-DrKfyo8j.js +47 -0
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/dist-node/src/conductor.applescript +134 -39
- package/dist-node/src/prefs.js +171 -0
- package/dist-node/src/routes.js +3 -0
- package/dist-node/src/server.js +26 -2
- package/dist-node/src/writes.js +34 -9
- package/package.json +1 -1
- package/dist/assets/index-CtlVLl-2.js +0 -47
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-DrKfyo8j.js"></script>
|
|
17
17
|
<link rel="stylesheet" crossorigin href="/assets/index-87r9u-0C.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:"e1e682e2e5e88fa03b7808ae9db8e098"},{url:"index.html",revision:"
|
|
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:"e1e682e2e5e88fa03b7808ae9db8e098"},{url:"index.html",revision:"d468165473ee790f5c716a2db97b8c94"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-DrKfyo8j.js",revision:null},{url:"assets/index-87r9u-0C.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\//]}))});
|
|
@@ -407,18 +407,22 @@ end webArea
|
|
|
407
407
|
on sidebarLinks()
|
|
408
408
|
-- The sidebar rows are AXLinks named "<repo> <title> +adds -dels". They live
|
|
409
409
|
-- two levels under the web area; collect them wherever they are at that depth.
|
|
410
|
+
--
|
|
411
|
+
-- `get` in front of each `whose` clause is load-bearing, and it is the whole
|
|
412
|
+
-- cost of this handler. Without it the clause stays a *reference*, so pulling
|
|
413
|
+
-- the elements out of it one at a time re-runs the filter against the live tree
|
|
414
|
+
-- for each one: measured on a 45-row sidebar, walking the references cost
|
|
415
|
+
-- 10.8s against 0.9s for the same query forced to a list once. That is paid by
|
|
416
|
+
-- every send the deep link doesn't answer and by every status change, which has
|
|
417
|
+
-- no path but this one.
|
|
410
418
|
set wa to my webArea()
|
|
411
419
|
tell application "System Events" to tell process "Conductor"
|
|
412
420
|
set out to {}
|
|
413
421
|
repeat with a in (UI elements of wa)
|
|
414
422
|
try
|
|
415
|
-
|
|
416
|
-
set end of out to contents of l
|
|
417
|
-
end repeat
|
|
423
|
+
set out to out & (get UI elements of a whose role is "AXLink")
|
|
418
424
|
repeat with b in (UI elements of a)
|
|
419
|
-
|
|
420
|
-
set end of out to contents of l
|
|
421
|
-
end repeat
|
|
425
|
+
set out to out & (get UI elements of b whose role is "AXLink")
|
|
422
426
|
end repeat
|
|
423
427
|
end try
|
|
424
428
|
end repeat
|
|
@@ -435,17 +439,22 @@ on findSidebarRow()
|
|
|
435
439
|
if (count of titles) is 0 then return missing value
|
|
436
440
|
set repoName to system attribute "RELAY_WS_REPO"
|
|
437
441
|
set rows to my sidebarLinks()
|
|
442
|
+
-- Every name once, ahead of the candidates, rather than once per candidate: each
|
|
443
|
+
-- is its own round trip, and there are up to four candidates over 45 rows. It is
|
|
444
|
+
-- also one snapshot, so a row that renames between candidates can't match twice.
|
|
445
|
+
-- Guarded read: the sidebar re-renders constantly (every commit changes a row's
|
|
446
|
+
-- "+adds -dels"), and one row going stale mid-scan must not take the search down.
|
|
447
|
+
set rowNames to {}
|
|
448
|
+
repeat with entry in rows
|
|
449
|
+
set end of rowNames to my axName(contents of entry)
|
|
450
|
+
end repeat
|
|
438
451
|
repeat with candidate in titles
|
|
439
452
|
if (candidate as text) is not "" then
|
|
440
453
|
set matches to {}
|
|
441
|
-
repeat with
|
|
442
|
-
set
|
|
443
|
-
-- Guarded read: the sidebar re-renders constantly (every commit
|
|
444
|
-
-- changes a row's "+adds -dels"), and one row that goes stale
|
|
445
|
-
-- mid-scan must not take the whole search down with it.
|
|
446
|
-
set rowName to my axName(row)
|
|
454
|
+
repeat with i from 1 to (count of rows)
|
|
455
|
+
set rowName to item i of rowNames
|
|
447
456
|
if rowName contains (candidate as text) then
|
|
448
|
-
if repoName is "" or rowName contains repoName then set end of matches to
|
|
457
|
+
if repoName is "" or rowName contains repoName then set end of matches to item i of rows
|
|
449
458
|
end if
|
|
450
459
|
end repeat
|
|
451
460
|
if (count of matches) is 1 then return item 1 of matches
|
|
@@ -563,10 +572,8 @@ on tabGroups()
|
|
|
563
572
|
repeat with entry in level
|
|
564
573
|
set node to contents of entry
|
|
565
574
|
try
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
end repeat
|
|
569
|
-
set nextLevel to nextLevel & (UI elements of node)
|
|
575
|
+
set found to found & (get UI elements of node whose role is "AXTabGroup")
|
|
576
|
+
set nextLevel to nextLevel & (get UI elements of node)
|
|
570
577
|
end try
|
|
571
578
|
end repeat
|
|
572
579
|
if (count of found) > 0 then return found
|
|
@@ -592,9 +599,7 @@ on stripRadios(tg)
|
|
|
592
599
|
set end of acc to node
|
|
593
600
|
else
|
|
594
601
|
try
|
|
595
|
-
|
|
596
|
-
set end of acc to contents of r
|
|
597
|
-
end repeat
|
|
602
|
+
set acc to acc & (get UI elements of node whose role is "AXRadioButton")
|
|
598
603
|
end try
|
|
599
604
|
end if
|
|
600
605
|
end repeat
|
|
@@ -714,7 +719,39 @@ on normalizeNewlines(s)
|
|
|
714
719
|
return joined
|
|
715
720
|
end normalizeNewlines
|
|
716
721
|
|
|
717
|
-
on
|
|
722
|
+
on composerField()
|
|
723
|
+
-- The composer's AXTextArea: an AXGroup named "composer" in the chat pane, one
|
|
724
|
+
-- text area inside it. Resolved once and handed to both the fill and the send
|
|
725
|
+
-- confirm below, because reaching it means walking the pane from the tab strip
|
|
726
|
+
-- and that walk measured ~0.5s on a live window. Returns missing value when the
|
|
727
|
+
-- composer isn't there at all, which is the caller's cue to fall back to pasting.
|
|
728
|
+
set strips to my tabGroups()
|
|
729
|
+
if (count of strips) is 0 then return missing value
|
|
730
|
+
try
|
|
731
|
+
tell application "System Events" to tell process "Conductor"
|
|
732
|
+
set pane to value of attribute "AXParent" of (item 1 of strips)
|
|
733
|
+
set composerBox to item 1 of (UI elements of pane whose name is "composer")
|
|
734
|
+
return item 1 of (UI elements of composerBox whose role is "AXTextArea")
|
|
735
|
+
end tell
|
|
736
|
+
end try
|
|
737
|
+
return missing value
|
|
738
|
+
end composerField
|
|
739
|
+
|
|
740
|
+
on composerTextOf(textBox)
|
|
741
|
+
-- What the box holds now, or missing value when it cannot be read at all. A pane
|
|
742
|
+
-- that re-rendered leaves the old reference pointing at a dead element, and
|
|
743
|
+
-- "can't read it" must never be reported as "still holding the prompt": that is
|
|
744
|
+
-- the one state that presses Enter again, and it would do so at the moment the
|
|
745
|
+
-- box may already be empty.
|
|
746
|
+
try
|
|
747
|
+
tell application "System Events" to tell process "Conductor"
|
|
748
|
+
return (value of textBox) as text
|
|
749
|
+
end tell
|
|
750
|
+
end try
|
|
751
|
+
return missing value
|
|
752
|
+
end composerTextOf
|
|
753
|
+
|
|
754
|
+
on fillComposer(textBox, promptText)
|
|
718
755
|
-- Write the prompt straight into the composer's AXTextArea instead of
|
|
719
756
|
-- stashing the clipboard, pressing Cmd+L and pasting. AXFocused and AXValue
|
|
720
757
|
-- are both settable, so this needs no keystrokes and no clipboard hijack.
|
|
@@ -722,13 +759,9 @@ on fillComposer(promptText)
|
|
|
722
759
|
-- *clears whatever it wrote first*: leaving half a prompt behind would make
|
|
723
760
|
-- the fallback paste append to it and send a garbled prompt.
|
|
724
761
|
if promptText is "" then return false
|
|
725
|
-
|
|
726
|
-
if (count of strips) is 0 then return false
|
|
762
|
+
if textBox is missing value then return false
|
|
727
763
|
try
|
|
728
764
|
tell application "System Events" to tell process "Conductor"
|
|
729
|
-
set pane to value of attribute "AXParent" of (item 1 of strips)
|
|
730
|
-
set composerBox to item 1 of (UI elements of pane whose name is "composer")
|
|
731
|
-
set textBox to item 1 of (UI elements of composerBox whose role is "AXTextArea")
|
|
732
765
|
set value of attribute "AXFocused" of textBox to true
|
|
733
766
|
set value of textBox to promptText
|
|
734
767
|
delay 0.25
|
|
@@ -739,23 +772,85 @@ on fillComposer(promptText)
|
|
|
739
772
|
end tell
|
|
740
773
|
on error
|
|
741
774
|
try
|
|
742
|
-
my clearComposer()
|
|
775
|
+
my clearComposer(textBox)
|
|
743
776
|
end try
|
|
744
777
|
return false
|
|
745
778
|
end try
|
|
746
779
|
return true
|
|
747
780
|
end fillComposer
|
|
748
781
|
|
|
749
|
-
on clearComposer()
|
|
750
|
-
|
|
751
|
-
if (count of strips) is 0 then return
|
|
782
|
+
on clearComposer(textBox)
|
|
783
|
+
if textBox is missing value then return
|
|
752
784
|
tell application "System Events" to tell process "Conductor"
|
|
753
|
-
set
|
|
754
|
-
set composerBox to item 1 of (UI elements of pane whose name is "composer")
|
|
755
|
-
set value of (item 1 of (UI elements of composerBox whose role is "AXTextArea")) to ""
|
|
785
|
+
set value of textBox to ""
|
|
756
786
|
end tell
|
|
757
787
|
end clearComposer
|
|
758
788
|
|
|
789
|
+
on pressSend(queueIt)
|
|
790
|
+
-- Return sends, Command-Return queues the prompt behind the running answer.
|
|
791
|
+
-- The choice is made out here because inside a System Events tell an ordinary
|
|
792
|
+
-- parameter name can resolve as one of its own terms.
|
|
793
|
+
if queueIt then
|
|
794
|
+
tell application "System Events" to key code 36 using {command down}
|
|
795
|
+
else
|
|
796
|
+
tell application "System Events" to key code 36
|
|
797
|
+
end if
|
|
798
|
+
end pressSend
|
|
799
|
+
|
|
800
|
+
on submitComposer(textBox, promptText, queueIt)
|
|
801
|
+
-- Press Enter, then read the composer back. Conductor consumes the draft when it
|
|
802
|
+
-- accepts a prompt, so a box still holding the text is a keystroke that did
|
|
803
|
+
-- nothing — and that read is the only receipt a run can collect about its own
|
|
804
|
+
-- send.
|
|
805
|
+
--
|
|
806
|
+
-- Why it earns its place: the caller's other receipt is the transcript row, which
|
|
807
|
+
-- costs a 6s watch (CONFIRM_WINDOW_MS in server.ts) and then a whole second run
|
|
808
|
+
-- through activate, focus, tab and fill. Measured from this Mac's own relay logs,
|
|
809
|
+
-- a send whose single fault was a first Enter Conductor ignored took ~10s from the
|
|
810
|
+
-- prompt appearing in the composer to it being sent, with the text sitting on
|
|
811
|
+
-- screen the whole way. Exactly one user row was written, so the keystroke was
|
|
812
|
+
-- lost rather than delayed.
|
|
813
|
+
--
|
|
814
|
+
-- Pressing again is safe *because* of the read, not in spite of it: the prompt is
|
|
815
|
+
-- still in the box, so nothing was consumed, so there is nothing to duplicate.
|
|
816
|
+
-- The one state that would break that is a Conductor which queues on Command-Return
|
|
817
|
+
-- and *keeps* the draft, since the box would then read as unsent after a send that
|
|
818
|
+
-- worked. Unverified against the live app, and the only path that could reach it is
|
|
819
|
+
-- Command-Return in the phone's composer (Composer.tsx, a hardware keyboard); a tap
|
|
820
|
+
-- always sends. Worth checking before anything else starts queueing.
|
|
821
|
+
-- A Conductor too busy to have handled the first Enter is also too busy to answer
|
|
822
|
+
-- an AX read, which blocks here rather than handing back stale text.
|
|
823
|
+
--
|
|
824
|
+
-- **Two presses, not three, and the second one is on probation.** The first
|
|
825
|
+
-- occurrence in the wild (2026-09-01 16:59:57) had the box survive all three, and
|
|
826
|
+
-- no send has ever been recorded as rescued by a later press — so the value here
|
|
827
|
+
-- is the *detection*, which lets the caller stop watching for a row that cannot
|
|
828
|
+
-- come (`sendNeverStarted` in writes.ts) rather than spending the 6s window on it.
|
|
829
|
+
-- Every press past the first that doesn't work is pure delay added to a send that
|
|
830
|
+
-- is already failing, which is why the budget shrank from 3×1.2s to 2×0.8s.
|
|
831
|
+
--
|
|
832
|
+
-- Returns the press that cleared the box, or 0 for a box that never cleared. The
|
|
833
|
+
-- count is the point: a rescued send is otherwise indistinguishable from one that
|
|
834
|
+
-- worked first time, so the failure this handler exists for would stop appearing
|
|
835
|
+
-- in the log the day it stopped costing 10s, and nobody could tell whether it was
|
|
836
|
+
-- fixed or merely hidden.
|
|
837
|
+
if promptText is "" then
|
|
838
|
+
my pressSend(queueIt)
|
|
839
|
+
return 1
|
|
840
|
+
end if
|
|
841
|
+
repeat with pressCount from 1 to 2
|
|
842
|
+
my pressSend(queueIt)
|
|
843
|
+
if textBox is missing value then return pressCount
|
|
844
|
+
repeat with tick from 1 to 4
|
|
845
|
+
delay 0.2
|
|
846
|
+
set seen to my composerTextOf(textBox)
|
|
847
|
+
if seen is missing value then return pressCount
|
|
848
|
+
if seen does not contain promptText then return pressCount
|
|
849
|
+
end repeat
|
|
850
|
+
end repeat
|
|
851
|
+
return 0
|
|
852
|
+
end submitComposer
|
|
853
|
+
|
|
759
854
|
on pasteComposer()
|
|
760
855
|
-- Fallback for when the composer isn't reachable: Cmd+L focuses it (after the
|
|
761
856
|
-- palette, focus sits on a button, not the text box), then paste.
|
|
@@ -830,7 +925,7 @@ end runStrip
|
|
|
830
925
|
on runTaskButton(tg)
|
|
831
926
|
set matches to {}
|
|
832
927
|
tell application "System Events" to tell process "Conductor"
|
|
833
|
-
repeat with entry in (UI elements of tg whose role is "AXButton")
|
|
928
|
+
repeat with entry in (get UI elements of tg whose role is "AXButton")
|
|
834
929
|
set node to contents of entry
|
|
835
930
|
set label to my axName(node)
|
|
836
931
|
if label starts with "Run " or label starts with "Stop " then set end of matches to node
|
|
@@ -858,7 +953,7 @@ end runTaskName
|
|
|
858
953
|
on openRunPorts(tg)
|
|
859
954
|
set found to {}
|
|
860
955
|
tell application "System Events" to tell process "Conductor"
|
|
861
|
-
repeat with entry in (UI elements of tg whose role is "AXButton")
|
|
956
|
+
repeat with entry in (get UI elements of tg whose role is "AXButton")
|
|
862
957
|
set label to my axName(contents of entry)
|
|
863
958
|
if label starts with "Open :" then set end of found to text 7 thru -1 of label
|
|
864
959
|
end repeat
|
|
@@ -1060,8 +1155,8 @@ on setModel(wanted)
|
|
|
1060
1155
|
set loose to {}
|
|
1061
1156
|
set wa to my webArea()
|
|
1062
1157
|
tell application "System Events" to tell process "Conductor"
|
|
1063
|
-
repeat with m in (UI elements of wa whose role is "AXMenu")
|
|
1064
|
-
repeat with mi in (UI elements of m whose role is "AXMenuItem")
|
|
1158
|
+
repeat with m in (get UI elements of wa whose role is "AXMenu")
|
|
1159
|
+
repeat with mi in (get UI elements of m whose role is "AXMenuItem")
|
|
1065
1160
|
set label to my firstLine(my tabLabel(mi))
|
|
1066
1161
|
if label is wanted then
|
|
1067
1162
|
set chosen to contents of mi
|
|
@@ -1106,8 +1201,8 @@ on listModels()
|
|
|
1106
1201
|
set labels to {}
|
|
1107
1202
|
set wa to my webArea()
|
|
1108
1203
|
tell application "System Events" to tell process "Conductor"
|
|
1109
|
-
repeat with m in (UI elements of wa whose role is "AXMenu")
|
|
1110
|
-
repeat with mi in (UI elements of m whose role is "AXMenuItem")
|
|
1204
|
+
repeat with m in (get UI elements of wa whose role is "AXMenu")
|
|
1205
|
+
repeat with mi in (get UI elements of m whose role is "AXMenuItem")
|
|
1111
1206
|
set end of labels to my firstLine(my tabLabel(mi))
|
|
1112
1207
|
end repeat
|
|
1113
1208
|
end repeat
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device-independent PWA state, durable on the host Mac.
|
|
3
|
+
*
|
|
4
|
+
* localStorage remains the live, offline-first copy. This store is the sync peer that
|
|
5
|
+
* survives a PWA origin change and lets another phone or browser pick up where the first
|
|
6
|
+
* stopped. It deliberately does not contain the access token, pending HTTP requests, or
|
|
7
|
+
* other device-scoped state.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { stateDir } from "./config.js";
|
|
12
|
+
const MAX_KEYS = 50_000;
|
|
13
|
+
const MAX_KEY_LENGTH = 256;
|
|
14
|
+
const MAX_MARK_LENGTH = 128;
|
|
15
|
+
const MAX_DRAFT_LENGTH = 1_000_000;
|
|
16
|
+
const MAX_AGENT_LABEL_LENGTH = 256;
|
|
17
|
+
function object(raw) {
|
|
18
|
+
return raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
|
|
19
|
+
}
|
|
20
|
+
function validKey(key) {
|
|
21
|
+
return key.length > 0 && key.length <= MAX_KEY_LENGTH;
|
|
22
|
+
}
|
|
23
|
+
function sanitizeAgent(raw) {
|
|
24
|
+
const value = object(raw);
|
|
25
|
+
if (!value)
|
|
26
|
+
return {};
|
|
27
|
+
const agent = {};
|
|
28
|
+
if (typeof value.model === 'string' && value.model.length <= MAX_AGENT_LABEL_LENGTH)
|
|
29
|
+
agent.model = value.model;
|
|
30
|
+
if (typeof value.effort === 'string' && value.effort.length <= MAX_AGENT_LABEL_LENGTH)
|
|
31
|
+
agent.effort = value.effort;
|
|
32
|
+
if (typeof value.plan === 'boolean')
|
|
33
|
+
agent.plan = value.plan;
|
|
34
|
+
if (typeof value.fast === 'boolean')
|
|
35
|
+
agent.fast = value.fast;
|
|
36
|
+
return agent;
|
|
37
|
+
}
|
|
38
|
+
function sanitizeReadMarks(raw) {
|
|
39
|
+
const value = object(raw);
|
|
40
|
+
if (!value)
|
|
41
|
+
return {};
|
|
42
|
+
const entries = Object.entries(value)
|
|
43
|
+
.filter((entry) => {
|
|
44
|
+
const [key, mark] = entry;
|
|
45
|
+
return validKey(key) && typeof mark === 'string' && mark.length > 0 && mark.length <= MAX_MARK_LENGTH;
|
|
46
|
+
})
|
|
47
|
+
.sort((a, b) => b[1].localeCompare(a[1]))
|
|
48
|
+
.slice(0, MAX_KEYS);
|
|
49
|
+
return Object.fromEntries(entries);
|
|
50
|
+
}
|
|
51
|
+
function sanitizeDraft(raw) {
|
|
52
|
+
const value = object(raw);
|
|
53
|
+
if (!value)
|
|
54
|
+
return null;
|
|
55
|
+
const updatedAt = Number(value.updatedAt);
|
|
56
|
+
if (!Number.isSafeInteger(updatedAt) || updatedAt < 0)
|
|
57
|
+
return null;
|
|
58
|
+
const deleted = value.deleted === true;
|
|
59
|
+
if (deleted)
|
|
60
|
+
return { text: '', agent: {}, updatedAt, deleted: true };
|
|
61
|
+
if (typeof value.text !== 'string' || value.text.length > MAX_DRAFT_LENGTH)
|
|
62
|
+
return null;
|
|
63
|
+
return { text: value.text, agent: sanitizeAgent(value.agent), updatedAt, deleted: false };
|
|
64
|
+
}
|
|
65
|
+
function sanitizeDrafts(raw) {
|
|
66
|
+
const value = object(raw);
|
|
67
|
+
if (!value)
|
|
68
|
+
return {};
|
|
69
|
+
const entries = [];
|
|
70
|
+
for (const [key, candidate] of Object.entries(value)) {
|
|
71
|
+
if (!validKey(key))
|
|
72
|
+
continue;
|
|
73
|
+
const draft = sanitizeDraft(candidate);
|
|
74
|
+
if (draft)
|
|
75
|
+
entries.push([key, draft]);
|
|
76
|
+
}
|
|
77
|
+
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt);
|
|
78
|
+
return Object.fromEntries(entries.slice(0, MAX_KEYS));
|
|
79
|
+
}
|
|
80
|
+
function sanitize(raw) {
|
|
81
|
+
const value = object(raw);
|
|
82
|
+
return {
|
|
83
|
+
readMarks: sanitizeReadMarks(value?.readMarks),
|
|
84
|
+
drafts: sanitizeDrafts(value?.drafts)
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function sameDraft(a, b) {
|
|
88
|
+
return (a.text === b.text &&
|
|
89
|
+
a.updatedAt === b.updatedAt &&
|
|
90
|
+
a.deleted === b.deleted &&
|
|
91
|
+
a.agent.model === b.agent.model &&
|
|
92
|
+
a.agent.effort === b.agent.effort &&
|
|
93
|
+
a.agent.plan === b.agent.plan &&
|
|
94
|
+
a.agent.fast === b.agent.fast);
|
|
95
|
+
}
|
|
96
|
+
/** Cached, single-process JSON store. A custom file keeps its merge rules easy to test. */
|
|
97
|
+
export class PrefsStore {
|
|
98
|
+
file;
|
|
99
|
+
cache = null;
|
|
100
|
+
constructor(file = path.join(stateDir(), 'prefs.json')) {
|
|
101
|
+
this.file = file;
|
|
102
|
+
}
|
|
103
|
+
read() {
|
|
104
|
+
if (this.cache)
|
|
105
|
+
return this.cache;
|
|
106
|
+
try {
|
|
107
|
+
this.cache = sanitize(JSON.parse(fs.readFileSync(this.file, 'utf8')));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
this.cache = { readMarks: {}, drafts: {} };
|
|
111
|
+
}
|
|
112
|
+
return this.cache;
|
|
113
|
+
}
|
|
114
|
+
/** Merge a client snapshot. Marks take max; draft revisions use LWW with deletion winning a tie. */
|
|
115
|
+
patch(raw) {
|
|
116
|
+
const input = object(raw);
|
|
117
|
+
if (!input)
|
|
118
|
+
return this.read();
|
|
119
|
+
const current = this.read();
|
|
120
|
+
const next = { readMarks: { ...current.readMarks }, drafts: { ...current.drafts } };
|
|
121
|
+
let changed = false;
|
|
122
|
+
if (Object.hasOwn(input, 'readMarks')) {
|
|
123
|
+
for (const [key, mark] of Object.entries(sanitizeReadMarks(input.readMarks))) {
|
|
124
|
+
if ((next.readMarks[key] ?? '') >= mark)
|
|
125
|
+
continue;
|
|
126
|
+
next.readMarks[key] = mark;
|
|
127
|
+
changed = true;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (Object.hasOwn(input, 'drafts')) {
|
|
131
|
+
for (const [key, draft] of Object.entries(sanitizeDrafts(input.drafts))) {
|
|
132
|
+
const previous = next.drafts[key];
|
|
133
|
+
const wins = !previous ||
|
|
134
|
+
draft.updatedAt > previous.updatedAt ||
|
|
135
|
+
(draft.updatedAt === previous.updatedAt && draft.deleted && !previous.deleted);
|
|
136
|
+
if (!wins || (previous && sameDraft(previous, draft)))
|
|
137
|
+
continue;
|
|
138
|
+
next.drafts[key] = draft;
|
|
139
|
+
changed = true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!changed)
|
|
143
|
+
return current;
|
|
144
|
+
this.persist(next);
|
|
145
|
+
this.cache = next;
|
|
146
|
+
return next;
|
|
147
|
+
}
|
|
148
|
+
persist(prefs) {
|
|
149
|
+
const dir = path.dirname(this.file);
|
|
150
|
+
const temporary = `${this.file}.${process.pid}.tmp`;
|
|
151
|
+
try {
|
|
152
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
153
|
+
fs.writeFileSync(temporary, `${JSON.stringify(prefs, null, '\t')}\n`, { mode: 0o600 });
|
|
154
|
+
fs.renameSync(temporary, this.file);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
try {
|
|
158
|
+
fs.unlinkSync(temporary);
|
|
159
|
+
}
|
|
160
|
+
catch { }
|
|
161
|
+
console.warn(`⚠ could not persist synced preferences (${err instanceof Error ? err.message : err})`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const store = new PrefsStore();
|
|
166
|
+
export function readPrefs() {
|
|
167
|
+
return store.read();
|
|
168
|
+
}
|
|
169
|
+
export function writePrefs(patch) {
|
|
170
|
+
return store.patch(patch);
|
|
171
|
+
}
|
package/dist-node/src/routes.js
CHANGED
|
@@ -64,6 +64,9 @@ export const routes = {
|
|
|
64
64
|
logs: flat('GET', '/api/logs'),
|
|
65
65
|
settings: flat('GET', '/api/settings'),
|
|
66
66
|
updateSettings: flat('PATCH', '/api/settings'),
|
|
67
|
+
/** Durable, device-independent PWA state. localStorage remains its offline-first mirror. */
|
|
68
|
+
prefs: flat('GET', '/api/prefs'),
|
|
69
|
+
updatePrefs: flat('PATCH', '/api/prefs'),
|
|
67
70
|
nosleep: flat('GET', '/api/nosleep'),
|
|
68
71
|
armNoSleep: flat('POST', '/api/nosleep'),
|
|
69
72
|
disarmNoSleep: flat('DELETE', '/api/nosleep'),
|
package/dist-node/src/server.js
CHANGED
|
@@ -21,6 +21,7 @@ import { armNoSleep, disarmNoSleep, MAX_SECONDS as NOSLEEP_MAX_SECONDS, nosleepS
|
|
|
21
21
|
import { chatRoute, notifyAll, notifyDevice, pushConfig, startNotifier, subscribeDevice, unsubscribeDevice } from "./notify.js";
|
|
22
22
|
import { ParkedPromptQueue } from "./parked.js";
|
|
23
23
|
import { attachPrStatus } from "./pr.js";
|
|
24
|
+
import { readPrefs, writePrefs } from "./prefs.js";
|
|
24
25
|
import { Reads } from "./reads.js";
|
|
25
26
|
import { isRoute, routeParam, routes } from "./routes.js";
|
|
26
27
|
import { foldHits, queryTokens, SearchIndex } from "./search.js";
|
|
@@ -30,7 +31,7 @@ import { discardStagedAttachment, materializeStagedAttachments, stageAttachment,
|
|
|
30
31
|
import { driftWarningLines, readExposeMode, tailscaleBin } from "./tailscale.js";
|
|
31
32
|
import { renderTranscript } from "./transcript.js";
|
|
32
33
|
import { autoJoinHotspotMode, currentSsid, looksLikeHotspot, preferredNetworks } from "./wifi.js";
|
|
33
|
-
import { createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, retryWontHelp, screenLocked, setAgentOptions, setRestartGuard, setWorkspaceStatus, stopTurn, UiBusyError, uiQueueDepth, WORKSPACE_STATUS_LABELS, withUiPriority } from "./writes.js";
|
|
34
|
+
import { createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, retryWontHelp, screenLocked, sendNeverStarted, setAgentOptions, setRestartGuard, setWorkspaceStatus, stopTurn, UiBusyError, uiQueueDepth, WORKSPACE_STATUS_LABELS, withUiPriority } from "./writes.js";
|
|
34
35
|
// Before anything that logs: from here on every console line is also kept in memory for
|
|
35
36
|
// `GET /api/logs`, so the phone can read why a send failed without ssh-ing into the Mac.
|
|
36
37
|
installLogCapture();
|
|
@@ -258,7 +259,14 @@ async function deliverPrompt(ws, sessionId, text, budgetMs = SEND_BUDGET_MS, que
|
|
|
258
259
|
deadline: deadline - MIN_CONFIRM_MS,
|
|
259
260
|
queue
|
|
260
261
|
});
|
|
261
|
-
|
|
262
|
+
// A run that left the prompt in the composer proved it wrote no row, so the
|
|
263
|
+
// window would be six seconds of watching for nothing. One check still happens:
|
|
264
|
+
// an *earlier* attempt's row can be arriving, and typing again over that is the
|
|
265
|
+
// duplicate this whole path exists to avoid.
|
|
266
|
+
const landed = sendNeverStarted(last.error)
|
|
267
|
+
? deliveredSince(sessionId, text, beforeRowid)
|
|
268
|
+
: await confirmDelivery(sessionId, text, beforeRowid, deadline);
|
|
269
|
+
if (landed) {
|
|
262
270
|
if (attempts > 1)
|
|
263
271
|
console.info(`[relay] send to ${label} landed on attempt ${attempts}`);
|
|
264
272
|
return { ok: true, strategy: last.strategy, attempts };
|
|
@@ -842,6 +850,22 @@ const server = http.createServer(async (req, res) => {
|
|
|
842
850
|
return json(req, res, 400, { error: 'nothing to change' });
|
|
843
851
|
return json(req, res, 200, { settings: writeSettings(patch) });
|
|
844
852
|
}
|
|
853
|
+
// PWA state remains local-first; this host copy survives origin changes and
|
|
854
|
+
// reconciles phones. PATCH accepts a full client snapshot and merges per key.
|
|
855
|
+
if (isRoute(routes.prefs, req.method, pathname)) {
|
|
856
|
+
return json(req, res, 200, { prefs: readPrefs() });
|
|
857
|
+
}
|
|
858
|
+
if (isRoute(routes.updatePrefs, req.method, pathname)) {
|
|
859
|
+
const raw = JSON.parse((await readBody(req)) || '{}');
|
|
860
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
861
|
+
return json(req, res, 400, { error: 'preferences must be an object' });
|
|
862
|
+
}
|
|
863
|
+
const body = raw;
|
|
864
|
+
if (!Object.hasOwn(body, 'readMarks') && !Object.hasOwn(body, 'drafts')) {
|
|
865
|
+
return json(req, res, 400, { error: 'nothing to sync' });
|
|
866
|
+
}
|
|
867
|
+
return json(req, res, 200, { prefs: writePrefs(body) });
|
|
868
|
+
}
|
|
845
869
|
// GET /api/nosleep — is the Mac being held awake, and can this relay do it at all
|
|
846
870
|
if (isRoute(routes.nosleep, req.method, pathname)) {
|
|
847
871
|
return json(req, res, 200, { ...(await nosleepState()), maxSeconds: NOSLEEP_MAX_SECONDS });
|
package/dist-node/src/writes.js
CHANGED
|
@@ -150,6 +150,20 @@ export function retryWontHelp(error) {
|
|
|
150
150
|
export function lockBlocked(error) {
|
|
151
151
|
return (error ?? '').includes('The Mac is locked');
|
|
152
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* A run that ended with the prompt still sitting in Conductor's composer
|
|
155
|
+
* (`submitComposer`). The draft was never consumed, so this run wrote no row and
|
|
156
|
+
* the caller's confirm window has nothing to wait for — six seconds spent watching
|
|
157
|
+
* for something the run already proved didn't happen. Only the *waiting* is
|
|
158
|
+
* skipped: an earlier attempt's row can still be arriving, so the caller checks
|
|
159
|
+
* once before typing again.
|
|
160
|
+
*
|
|
161
|
+
* Matched on the phrase the send script writes itself, like `lockBlocked` above,
|
|
162
|
+
* so macOS wording can't drift under it.
|
|
163
|
+
*/
|
|
164
|
+
export function sendNeverStarted(error) {
|
|
165
|
+
return (error ?? '').includes('still sitting in its composer');
|
|
166
|
+
}
|
|
153
167
|
/**
|
|
154
168
|
* Node's own read of the lock screen — the same CGSessionCopyCurrentDictionary
|
|
155
169
|
* probe `screenLocked()` in conductor.applescript makes, minus the AppleScript
|
|
@@ -373,6 +387,14 @@ export class AppleScriptActuator {
|
|
|
373
387
|
// Open the target workspace's own link, confirm its chat tab, fill the composer, send.
|
|
374
388
|
// Filling is an Accessibility write (no keystrokes, no clipboard); the
|
|
375
389
|
// clipboard paste is kept only as a fallback, and stashes/restores around it.
|
|
390
|
+
//
|
|
391
|
+
// The send is then read back rather than assumed (`submitComposer`): Conductor
|
|
392
|
+
// consumes the draft when it takes a prompt, so a composer that still holds the
|
|
393
|
+
// text is an Enter that went nowhere, and pressing again inside this run costs
|
|
394
|
+
// under a second. Left to `deliverPrompt` the same failure costs the 6s confirm
|
|
395
|
+
// window plus a whole second run, which is the ~10s of prompt-sitting-on-screen
|
|
396
|
+
// this Mac's logs recorded. A composer that survives three presses is stuck on
|
|
397
|
+
// something the script can't see, so it errors and lets that retry take over.
|
|
376
398
|
const script = `
|
|
377
399
|
${CONDUCTOR_HANDLERS}
|
|
378
400
|
|
|
@@ -380,19 +402,16 @@ my activateConductor()
|
|
|
380
402
|
my focusWorkspace()
|
|
381
403
|
my selectChatTab()
|
|
382
404
|
set promptText to my normalizeNewlines(do shell script "cat" & " " & quoted form of (system attribute "RELAY_PROMPT_FILE"))
|
|
383
|
-
|
|
405
|
+
set textBox to my composerField()
|
|
406
|
+
if not (my fillComposer(textBox, promptText)) then
|
|
384
407
|
set savedClipboard to the clipboard
|
|
385
408
|
my pasteComposer()
|
|
386
409
|
delay 0.1
|
|
387
410
|
set the clipboard to savedClipboard
|
|
388
411
|
end if
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
else
|
|
393
|
-
key code 36
|
|
394
|
-
end if
|
|
395
|
-
end tell
|
|
412
|
+
set presses to my submitComposer(textBox, promptText, (system attribute "RELAY_QUEUE_PROMPT") is "1")
|
|
413
|
+
if presses is 0 then error "Conductor ignored Enter - the prompt is still sitting in its composer"
|
|
414
|
+
return "presses:" & presses
|
|
396
415
|
`.trim();
|
|
397
416
|
// Pass the prompt via a temp file + env to avoid AppleScript string escaping.
|
|
398
417
|
const os = await import('node:os');
|
|
@@ -401,7 +420,7 @@ end tell
|
|
|
401
420
|
const tmp = path.join(os.tmpdir(), `relay-prompt-${process.pid}-${Date.now()}.txt`);
|
|
402
421
|
await fs.writeFile(tmp, text, 'utf8');
|
|
403
422
|
try {
|
|
404
|
-
await withTargetEnvironment(target, targetEnvironment => uiTurn(() => exec('osascript', ['-e', script], {
|
|
423
|
+
const { stdout } = await withTargetEnvironment(target, targetEnvironment => uiTurn(() => exec('osascript', ['-e', script], {
|
|
405
424
|
env: {
|
|
406
425
|
...process.env,
|
|
407
426
|
RELAY_PROMPT_FILE: tmp,
|
|
@@ -410,6 +429,12 @@ end tell
|
|
|
410
429
|
},
|
|
411
430
|
timeout: runCeiling(deadline)
|
|
412
431
|
})));
|
|
432
|
+
// A rescued send is otherwise indistinguishable from one that worked first
|
|
433
|
+
// time, so the failure would leave the log whether it was fixed or hidden.
|
|
434
|
+
const presses = Number(stdout.match(/presses:(\d+)/)?.[1] ?? 1);
|
|
435
|
+
if (presses > 1) {
|
|
436
|
+
console.warn(`[relay] Conductor ignored Enter — the composer cleared on press ${presses}`);
|
|
437
|
+
}
|
|
413
438
|
return { ok: true, strategy: this.name };
|
|
414
439
|
}
|
|
415
440
|
catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "conductor-remote",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.66.0",
|
|
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.",
|