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/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-CtlVLl-2.js"></script>
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:"caee395fa7cb2e359d60eee9e6309178"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-CtlVLl-2.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\//]}))});
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
- repeat with l in (UI elements of a whose role is "AXLink")
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
- repeat with l in (UI elements of b whose role is "AXLink")
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 entry in rows
442
- set row to contents of entry
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 row
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
- repeat with h in (UI elements of node whose role is "AXTabGroup")
567
- set end of found to contents of h
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
- repeat with r in (UI elements of node whose role is "AXRadioButton")
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 fillComposer(promptText)
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
- set strips to my tabGroups()
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
- set strips to my tabGroups()
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 pane to value of attribute "AXParent" of (item 1 of strips)
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
+ }
@@ -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'),
@@ -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
- if (await confirmDelivery(sessionId, text, beforeRowid, deadline)) {
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 });
@@ -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
- if not (my fillComposer(promptText)) then
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
- tell application "System Events"
390
- if (system attribute "RELAY_QUEUE_PROMPT") is "1" then
391
- key code 36 using {command down}
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.65.0",
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.",