conductor-remote 1.65.1 → 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-fu-yJ_gW.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:"96dea2763a1c11d8262211107568a7e1"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-fu-yJ_gW.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
@@ -808,11 +813,21 @@ on submitComposer(textBox, promptText, queueIt)
808
813
  --
809
814
  -- Pressing again is safe *because* of the read, not in spite of it: the prompt is
810
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.
811
821
  -- A Conductor too busy to have handled the first Enter is also too busy to answer
812
- -- an AX read, which blocks here rather than handing back stale text. Bounded at
813
- -- three presses — a box that survives those is stuck on something this script
814
- -- cannot see, and the caller's own retry, which re-focuses and re-fills, is the
815
- -- better lever than a fourth keystroke.
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.
816
831
  --
817
832
  -- Returns the press that cleared the box, or 0 for a box that never cleared. The
818
833
  -- count is the point: a rescued send is otherwise indistinguishable from one that
@@ -823,10 +838,10 @@ on submitComposer(textBox, promptText, queueIt)
823
838
  my pressSend(queueIt)
824
839
  return 1
825
840
  end if
826
- repeat with pressCount from 1 to 3
841
+ repeat with pressCount from 1 to 2
827
842
  my pressSend(queueIt)
828
843
  if textBox is missing value then return pressCount
829
- repeat with tick from 1 to 6
844
+ repeat with tick from 1 to 4
830
845
  delay 0.2
831
846
  set seen to my composerTextOf(textBox)
832
847
  if seen is missing value then return pressCount
@@ -910,7 +925,7 @@ end runStrip
910
925
  on runTaskButton(tg)
911
926
  set matches to {}
912
927
  tell application "System Events" to tell process "Conductor"
913
- 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")
914
929
  set node to contents of entry
915
930
  set label to my axName(node)
916
931
  if label starts with "Run " or label starts with "Stop " then set end of matches to node
@@ -938,7 +953,7 @@ end runTaskName
938
953
  on openRunPorts(tg)
939
954
  set found to {}
940
955
  tell application "System Events" to tell process "Conductor"
941
- 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")
942
957
  set label to my axName(contents of entry)
943
958
  if label starts with "Open :" then set end of found to text 7 thru -1 of label
944
959
  end repeat
@@ -1140,8 +1155,8 @@ on setModel(wanted)
1140
1155
  set loose to {}
1141
1156
  set wa to my webArea()
1142
1157
  tell application "System Events" to tell process "Conductor"
1143
- repeat with m in (UI elements of wa whose role is "AXMenu")
1144
- 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")
1145
1160
  set label to my firstLine(my tabLabel(mi))
1146
1161
  if label is wanted then
1147
1162
  set chosen to contents of mi
@@ -1186,8 +1201,8 @@ on listModels()
1186
1201
  set labels to {}
1187
1202
  set wa to my webArea()
1188
1203
  tell application "System Events" to tell process "Conductor"
1189
- repeat with m in (UI elements of wa whose role is "AXMenu")
1190
- 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")
1191
1206
  set end of labels to my firstLine(my tabLabel(mi))
1192
1207
  end repeat
1193
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.65.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.",