conductor-remote 1.46.0 → 1.46.2

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-C_IDJcQQ.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-Cox9xTfw.js"></script>
17
17
  <link rel="stylesheet" crossorigin href="/assets/index-DGZkOBT-.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:"53f877045e9ef9cf1f2830e0a520d8fe"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-DGZkOBT-.css",revision:null},{url:"assets/index-C_IDJcQQ.js",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 c=e=>i(e,o),t={module:{uri:o},exports:l,require:c};s[o]=Promise.all(n.map(e=>t[e]||c(e))).then(e=>(r(...e),l))}}define(["./workbox-9c191d2f"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"e1e682e2e5e88fa03b7808ae9db8e098"},{url:"index.html",revision:"039e5ab1adab0c250569cb906306c5a8"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-DGZkOBT-.css",revision:null},{url:"assets/index-Cox9xTfw.js",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\//]}))});
@@ -908,6 +908,20 @@ on firstLine(s)
908
908
  return item 1 of parts
909
909
  end firstLine
910
910
 
911
+ on pickerShowsModel(popup, wanted)
912
+ set pickerLabel to my tabLabel(popup)
913
+ if pickerLabel contains ("(" & wanted & ")") then return true
914
+ -- The menu can label a newly released model "Opus 5 NEW", while the
915
+ -- composer deliberately drops that temporary badge and says "Opus 5".
916
+ -- Treat that exact display form as the same choice; the surrounding
917
+ -- parentheses keep "Opus 5" distinct from a longer model name.
918
+ if wanted ends with " NEW" then
919
+ set baseName to text 1 thru -5 of wanted
920
+ if pickerLabel contains ("(" & baseName & ")") then return true
921
+ end if
922
+ return false
923
+ end pickerShowsModel
924
+
911
925
  on setModel(wanted)
912
926
  set popup to missing value
913
927
  repeat with entry in my composerControls()
@@ -915,13 +929,14 @@ on setModel(wanted)
915
929
  if my tabLabel(c) contains "Change agent" then set popup to c
916
930
  end repeat
917
931
  if popup is missing value then error "couldn't find the model picker"
918
- if (my tabLabel(popup)) contains ("(" & wanted & ")") then return
932
+ if my pickerShowsModel(popup, wanted) then return
919
933
  tell application "System Events" to tell process "Conductor"
920
934
  perform action "AXPress" of popup
921
935
  end tell
922
936
  delay 1.0
923
- -- Menu labels carry badges ("Opus 5 NEW"), so an exact match is preferred but a
924
- -- prefix match is accepted except when it is ambiguous ("Sonnet 4.6" would
937
+ -- The relay removes a temporary `NEW` badge before it sends this label back,
938
+ -- so a prefix match resolves that menu item. It is accepted only when unique:
939
+ -- "Sonnet 4.6" would
925
940
  -- otherwise also match "Sonnet 4.6 1M"), which must fail rather than guess.
926
941
  set chosen to missing value
927
942
  set loose to {}
@@ -954,7 +969,7 @@ on setModel(wanted)
954
969
  if my tabLabel(c) contains "Change agent" then set popup to c
955
970
  end repeat
956
971
  if popup is missing value then error "the model picker vanished"
957
- if (my tabLabel(popup)) does not contain ("(" & wanted & ")") then error "the model didn't switch to " & wanted
972
+ if not my pickerShowsModel(popup, wanted) then error "the model didn't switch to " & wanted
958
973
  end setModel
959
974
 
960
975
  on listModels()
@@ -235,53 +235,132 @@ function oneLine(text) {
235
235
  .trim(), BODY_CHARS);
236
236
  }
237
237
  // --- the watcher ---
238
- /** Last seen status per session. Null means "re-baseline on the next tick" (nobody was subscribed). */
239
- let previous = null;
240
- /** Transitions seen once and awaiting a second tick's confirmation. */
241
- const armed = new Map();
242
- function tick(reads) {
243
- if (deviceCount() === 0) {
244
- // Nothing to notify. Forget the snapshot so re-enabling starts from the world as
245
- // it is then, rather than replaying every turn that ended while nobody listened.
246
- previous = null;
247
- armed.clear();
248
- return;
249
- }
250
- const states = reads.listSessionStates();
251
- const current = new Map(states.map(s => [s.sessionId, s.status ?? null]));
252
- if (previous === null) {
253
- previous = current;
254
- return;
238
+ /**
239
+ * Everything this chat records about a person having asked for something, as one
240
+ * comparable string — the turn head *and* the last user message, because steering a
241
+ * running turn moves only the second (`queue_order` is null on a steering message, see
242
+ * reads.ts). Empty when the chat records neither, which is what `selfScheduled` reads
243
+ * as "no evidence" rather than as "nobody asked".
244
+ */
245
+ function askedBy(state) {
246
+ if (!state.turnStartedAt && !state.lastUserMessageAt)
247
+ return '';
248
+ return `${state.turnStartedAt ?? ''}|${state.lastUserMessageAt ?? ''}`;
249
+ }
250
+ /**
251
+ * The status state machine, kept apart from the store and the network so it can be
252
+ * driven a tick at a time by `scripts/check-notify.ts`. Every rule in it is a rule
253
+ * about *not* notifying, and each one is a nuisance or a silence that nothing in the
254
+ * type system can catch.
255
+ */
256
+ export class TurnWatcher {
257
+ /** Last seen status per chat. Null means "re-baseline on the next step" (nobody was subscribed). */
258
+ previous = null;
259
+ /** Transitions seen once and awaiting a second step's confirmation. */
260
+ armed = new Map();
261
+ /**
262
+ * Who-asked, as of the last turn we said "done" about, per chat — what makes a
263
+ * self-scheduled turn quiet.
264
+ *
265
+ * `working → idle` is still the only signal Conductor gives, and it is still the right
266
+ * one for a turn *someone asked for*. It is the wrong one for a turn the agent
267
+ * scheduled itself: a `/loop` starts a fresh turn every few minutes for as long as it
268
+ * runs, each one ends properly, and each one is news to nobody. Measured on this Mac —
269
+ * one looping chat pushed roughly every five minutes from early evening until past
270
+ * midnight, and again all morning, which was most of the notifications the phone got
271
+ * at all.
272
+ *
273
+ * The tell is in the data rather than in a guess about intent. A turn is headed by a
274
+ * `session_messages` row with `queue_order` set and a loop's next lap writes nothing
275
+ * at all, so `askedBy` below sits still while `status` cycles. The first lap after you
276
+ * type something notifies. Every lap the agent gave itself is quiet, until you say the
277
+ * next thing. An `error` is exempt — a loop that breaks is worth hearing about however
278
+ * it started.
279
+ *
280
+ * A chat with nothing to compare (no turn head *and* no user message — dormant since
281
+ * before `queue_order` landed in May 2026) notifies every time, which is the old
282
+ * behaviour: with no evidence either way, silence is the dangerous default.
283
+ */
284
+ notifiedTurn = new Map();
285
+ /**
286
+ * Nobody is subscribed. Forget the snapshot so re-enabling starts from the world as
287
+ * it is then, rather than replaying every turn that ended while nobody listened.
288
+ */
289
+ reset() {
290
+ this.previous = null;
291
+ this.armed.clear();
292
+ this.notifiedTurn.clear();
255
293
  }
256
- const baseline = previous;
257
- for (const state of states) {
258
- const now = state.status ?? null;
259
- const before = baseline.get(state.sessionId);
260
- const pendingKind = armed.get(state.sessionId);
261
- if (pendingKind) {
262
- armed.delete(state.sessionId);
263
- // Confirmed only if the new status held for a second tick; a flap just drops the arm.
264
- if ((pendingKind === 'done' && now === 'idle') || (pendingKind === 'error' && now === 'error')) {
265
- void fire(reads, state.sessionId, pendingKind, state);
294
+ /** One poll of every live chat, in; the notifications it earned, out. */
295
+ step(states) {
296
+ const current = new Map(states.map(s => [s.sessionId, s.status ?? null]));
297
+ if (this.previous === null) {
298
+ this.previous = current;
299
+ return [];
300
+ }
301
+ const baseline = this.previous;
302
+ const due = [];
303
+ for (const state of states) {
304
+ const now = state.status ?? null;
305
+ const before = baseline.get(state.sessionId);
306
+ const pendingKind = this.armed.get(state.sessionId);
307
+ if (pendingKind) {
308
+ this.armed.delete(state.sessionId);
309
+ // Confirmed only if the new status held for a second step; a flap just drops the arm.
310
+ const held = (pendingKind === 'done' && now === 'idle') || (pendingKind === 'error' && now === 'error');
311
+ if (held && !(pendingKind === 'done' && this.selfScheduled(state))) {
312
+ const asked = askedBy(state);
313
+ if (pendingKind === 'done' && asked)
314
+ this.notifiedTurn.set(state.sessionId, asked);
315
+ due.push({ state, kind: pendingKind });
316
+ }
317
+ continue;
266
318
  }
267
- continue;
319
+ // A chat we've never seen (a new one, or the first step after re-baselining)
320
+ // contributes its status to the snapshot but is never itself news.
321
+ if (before === undefined)
322
+ continue;
323
+ if (before === 'working' && now === 'idle')
324
+ this.armed.set(state.sessionId, 'done');
325
+ else if (before !== 'error' && now === 'error')
326
+ this.armed.set(state.sessionId, 'error');
268
327
  }
269
- // A session we've never seen (a new chat, or the first tick after re-baselining)
270
- // contributes its status to the snapshot but is never itself news.
271
- if (before === undefined)
272
- continue;
273
- if (before === 'working' && now === 'idle')
274
- armed.set(state.sessionId, 'done');
275
- else if (before !== 'error' && now === 'error')
276
- armed.set(state.sessionId, 'error');
328
+ // A chat armed on the last step can vanish before this one (its workspace was
329
+ // archived mid-turn). Nothing above touches it, so drop it here rather than keep
330
+ // the key forever — and never notify about a workspace that no longer exists.
331
+ for (const sessionId of this.armed.keys())
332
+ if (!current.has(sessionId))
333
+ this.armed.delete(sessionId);
334
+ for (const sessionId of this.notifiedTurn.keys())
335
+ if (!current.has(sessionId))
336
+ this.notifiedTurn.delete(sessionId);
337
+ this.previous = current;
338
+ return due;
339
+ }
340
+ /**
341
+ * Did this chat end a turn nobody asked for? See `notifiedTurn`.
342
+ *
343
+ * The first `working → idle` a chat shows us always notifies, whatever started it: the
344
+ * relay may have only just come up, or the phone only just subscribed, and staying
345
+ * quiet about a turn we have no history for would lose the one notification that
346
+ * mattered. It is the *repeat* on an unchanged turn head that is the loop.
347
+ */
348
+ selfScheduled(state) {
349
+ const asked = askedBy(state);
350
+ if (!asked)
351
+ return false;
352
+ const last = this.notifiedTurn.get(state.sessionId);
353
+ return last !== undefined && last === asked;
354
+ }
355
+ }
356
+ const watcher = new TurnWatcher();
357
+ function tick(reads) {
358
+ if (deviceCount() === 0) {
359
+ watcher.reset();
360
+ return;
277
361
  }
278
- // A session armed on the last tick can vanish before this one (its workspace was
279
- // archived mid-turn). Nothing above touches it, so drop it here rather than keep
280
- // the key forever — and never notify about a workspace that no longer exists.
281
- for (const sessionId of armed.keys())
282
- if (!current.has(sessionId))
283
- armed.delete(sessionId);
284
- previous = current;
362
+ for (const due of watcher.step(reads.listSessionStates()))
363
+ void fire(reads, due.state.sessionId, due.kind, due.state);
285
364
  }
286
365
  async function fire(reads, sessionId, kind, state) {
287
366
  const said = reads.lastAssistantText(sessionId);
@@ -248,10 +248,19 @@ export class Reads {
248
248
  * `setting_up` workspaces are excluded; their chat isn't the user's turn yet.
249
249
  */
250
250
  listSessionStates() {
251
- const rows = this.db.query(`SELECT s.id, s.status, s.title, s.workspace_id,
251
+ const rows = this.db.query(
252
+ // These two together are the notifier's record of whether a *person* had
253
+ // anything to do with the turn that just ended. An agent that schedules its own
254
+ // next turn (a `/loop`) writes no message at all, so both stay put while
255
+ // `status` cycles working → idle on every lap. Both are needed: `turn_started_at`
256
+ // only moves for a message that *heads* a turn, so steering into a running one
257
+ // would look like a lap nobody asked for. See src/notify.ts.
258
+ `SELECT s.id, s.status, s.title, s.workspace_id, s.last_user_message_at,
252
259
  w.workspace_name, w.pr_title, w.branch, w.directory_name,
253
260
  r.name AS repo_name,
254
- (SELECT COUNT(*) FROM sessions t WHERE t.workspace_id = w.id AND COALESCE(t.is_hidden, 0) = 0) AS tab_count
261
+ (SELECT COUNT(*) FROM sessions t WHERE t.workspace_id = w.id AND COALESCE(t.is_hidden, 0) = 0) AS tab_count,
262
+ (SELECT MAX(m.sent_at) FROM session_messages m
263
+ WHERE m.session_id = s.id AND m.queue_order IS NOT NULL AND m.sent_at IS NOT NULL) AS turn_started_at
255
264
  FROM sessions s
256
265
  JOIN workspaces w ON w.id = s.workspace_id
257
266
  LEFT JOIN repos r ON r.id = w.repository_id
@@ -260,6 +269,8 @@ export class Reads {
260
269
  sessionId: r.id,
261
270
  workspaceId: r.workspace_id,
262
271
  status: r.status,
272
+ turnStartedAt: r.turn_started_at,
273
+ lastUserMessageAt: r.last_user_message_at,
263
274
  workspaceTitle: workspaceTitle({ ...r, id: r.workspace_id }),
264
275
  repoName: r.repo_name,
265
276
  // A single-tab workspace's chat title is just the workspace again — only name it when it disambiguates.
@@ -0,0 +1,109 @@
1
+ /**
2
+ * One prompt per intent, however many requests carry it.
3
+ *
4
+ * `deliverPrompt` already makes a send safe to repeat *within* a request: it
5
+ * snapshots the transcript cursor, runs, and watches for the matching user row, so
6
+ * a run that landed late is reported as delivered rather than typed again. What that
7
+ * cannot see is a copy landed by a **different request**, because the snapshot is
8
+ * taken when the request starts and the earlier copy is behind it.
9
+ *
10
+ * Which is the duplicate the chats here actually hold. The failure is not the relay
11
+ * fumbling a send, it is the *answer* going missing: the funnel ingress goes stale on
12
+ * a network change (a documented failure on this setup), or the phone is suspended,
13
+ * or the 75s client budget runs out while the relay is still inside its own 55s one.
14
+ * The prompt is in Conductor, the phone never hears so, the bubble flips to failed,
15
+ * and Retry is right there. Two rows, seconds apart, and nothing in relay.log —
16
+ * every retry the relay *did* log landed exactly once, which is how the two were told
17
+ * apart.
18
+ *
19
+ * So the phone names the intent instead. `PendingMessage.id` already exists and the
20
+ * Retry button already reuses it (a fresh send makes a fresh one), so it is a UUID
21
+ * per thing-the-user-meant-to-say, and that is the key here. Deliberately saying
22
+ * "yes" twice is two ids and still two prompts; tapping Retry on one failed bubble is
23
+ * one id and stays one prompt. Nothing is matched on text, which is what keeps this
24
+ * from swallowing a repeat someone meant.
25
+ *
26
+ * Three properties:
27
+ *
28
+ * - **A failure is never remembered.** Retry after a send that really didn't land
29
+ * must run again. Memoising failures would turn one bad minute into a Retry button
30
+ * that does nothing for as long as the entry lives, which is worse than the
31
+ * duplicate this exists to stop. `keep` decides, and it is given the outcome.
32
+ * - **In flight counts as delivered-in-progress.** The phone gives up at 75s and the
33
+ * relay's own budget is 55s, so a Retry can arrive while the first run is still
34
+ * holding `uiTurn`. A second run would then queue behind the first and type the
35
+ * same prompt again the moment it finished. Joining the first is both the correct
36
+ * answer and the faster one.
37
+ * - **No key, no memo.** An MCP caller or an older cached PWA sends none, and gets
38
+ * exactly today's behaviour rather than a guess about what it meant.
39
+ */
40
+ /**
41
+ * How long a delivered outcome stays worth answering with. It only has to outlast
42
+ * the gap between a lost answer and the tap that follows it — the phone's 75s budget,
43
+ * then however long it takes someone to look at their screen. Generous is free here:
44
+ * the key is a UUID, so an entry can never match an intent it didn't come from, and
45
+ * the only cost of keeping one is the row in the map.
46
+ */
47
+ const DEFAULT_TTL_MS = 10 * 60 * 1000;
48
+ /**
49
+ * Runs keyed work at most once per key: concurrent callers share one run, and a
50
+ * kept outcome answers later ones without running at all.
51
+ */
52
+ export class SendOnce {
53
+ inFlight = new Map();
54
+ settled = new Map();
55
+ ttlMs;
56
+ keep;
57
+ constructor(opts) {
58
+ this.keep = opts.keep;
59
+ this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
60
+ }
61
+ /**
62
+ * `key` undefined runs `fn` plainly — see "No key, no memo" above. Otherwise the
63
+ * first caller runs it, callers arriving while it runs await that same run, and
64
+ * callers arriving after a *kept* outcome get it back without running.
65
+ */
66
+ async run(key, fn) {
67
+ if (!key)
68
+ return fn();
69
+ const remembered = this.recall(key);
70
+ if (remembered)
71
+ return remembered.value;
72
+ const running = this.inFlight.get(key);
73
+ if (running)
74
+ return running;
75
+ // Wrapped rather than called bare, so a synchronous throw leaves no entry behind
76
+ // and reaches the caller as a rejection like every other failure.
77
+ const started = (async () => fn())();
78
+ this.inFlight.set(key, started);
79
+ try {
80
+ const value = await started;
81
+ if (this.keep(value)) {
82
+ this.prune();
83
+ this.settled.set(key, { at: Date.now(), value });
84
+ }
85
+ return value;
86
+ }
87
+ finally {
88
+ this.inFlight.delete(key);
89
+ }
90
+ }
91
+ /** Whether this key already has an outcome worth answering with — the test's window in. */
92
+ recall(key) {
93
+ const found = this.settled.get(key);
94
+ if (!found)
95
+ return null;
96
+ if (Date.now() - found.at > this.ttlMs) {
97
+ this.settled.delete(key);
98
+ return null;
99
+ }
100
+ return found;
101
+ }
102
+ /** Entries expire on their own; this is what stops the map outliving them. */
103
+ prune() {
104
+ const cutoff = Date.now() - this.ttlMs;
105
+ for (const [key, entry] of this.settled)
106
+ if (entry.at <= cutoff)
107
+ this.settled.delete(key);
108
+ }
109
+ }
@@ -20,6 +20,7 @@ import { attachPrStatus } from "./pr.js";
20
20
  import { Reads } from "./reads.js";
21
21
  import { isRoute, routeParam, routes } from "./routes.js";
22
22
  import { foldHits, queryTokens, SearchIndex } from "./search.js";
23
+ import { SendOnce } from "./sendonce.js";
23
24
  import { readSettings, writeSettings } from "./settings.js";
24
25
  import { driftWarningLines, tailscaleBin } from "./tailscale.js";
25
26
  import { renderTranscript } from "./transcript.js";
@@ -325,6 +326,16 @@ async function applyAgentPatch(ws, sessionId, patch) {
325
326
  }
326
327
  return { ok: true };
327
328
  }
329
+ /**
330
+ * One prompt per tap, however many requests carry it (src/sendonce.ts). Keyed on the
331
+ * phone's own `PendingMessage.id`, which Retry reuses and a fresh send re-rolls, so a
332
+ * repeat someone meant still goes twice. Only an answer the phone would treat as final
333
+ * is remembered: a real failure has to stay retryable, or Retry does nothing for ten
334
+ * minutes and the prompt is lost for good rather than merely doubled.
335
+ */
336
+ const sendOnce = new SendOnce({
337
+ keep: answer => answer.status === 200 || answer.status === 202
338
+ });
328
339
  /** What the phone is told when its prompt is parked instead of failed. */
329
340
  const PARKED_ERROR = 'The Mac is locked — the relay parked the prompt and will send it when the Mac is unlocked.';
330
341
  /**
@@ -993,45 +1004,49 @@ const server = http.createServer(async (req, res) => {
993
1004
  if (agent?.effort && !EFFORT_LABELS[agent.effort]) {
994
1005
  return json(req, res, 400, { error: `effort must be one of ${Object.keys(EFFORT_LABELS).join(', ')}` });
995
1006
  }
996
- if (agent) {
997
- const applied = await applyAgentPatch(ws, sessionId, agent);
998
- if (!applied.ok) {
999
- if (lockBlocked(applied.error)) {
1000
- const queued = parkedPrompts.park(ws.id, sessionId, text, agent);
1001
- return json(req, res, 202, {
1002
- ok: false,
1003
- parked: true,
1004
- queued,
1005
- strategy: actuator.name,
1006
- error: PARKED_ERROR
1007
- });
1007
+ // One prompt per intent (src/sendonce.ts). Everything that can *say something
1008
+ // to Conductor* sits inside, so an answer the phone never heard is replayed
1009
+ // rather than re-performed — including the parked branches, since parking the
1010
+ // same intent twice queues the same prompt twice for the unlock.
1011
+ if (body.clientId && sendOnce.recall(body.clientId)) {
1012
+ console.info(`[relay] send to ${ws.branch ?? ws.id} already delivered for this tap — answering, not resending`);
1013
+ }
1014
+ const answer = await sendOnce.run(body.clientId, async () => {
1015
+ if (agent) {
1016
+ const applied = await applyAgentPatch(ws, sessionId, agent);
1017
+ if (!applied.ok) {
1018
+ if (lockBlocked(applied.error)) {
1019
+ const queued = parkedPrompts.park(ws.id, sessionId, text, agent);
1020
+ return {
1021
+ status: 202,
1022
+ body: { ok: false, parked: true, queued, strategy: actuator.name, error: PARKED_ERROR }
1023
+ };
1024
+ }
1025
+ return { status: 502, body: { ok: false, strategy: actuator.name, error: applied.error } };
1008
1026
  }
1009
- return json(req, res, 502, { ok: false, strategy: actuator.name, error: applied.error });
1010
1027
  }
1011
- }
1012
- // Retries live inside deliverPrompt, confirmed against the transcript each time,
1013
- // and inside the deadline this phone told us it would wait.
1014
- const result = await deliverPrompt(ws, sessionId, text, deadline - Date.now());
1015
- if (result.ok) {
1016
- // Whatever a queue was still holding has now been said by hand — the first
1017
- // prompt (including a failed entry retried from the chat), and any parked
1018
- // copy of this exact text, which delivering again would double.
1019
- firstPrompts.forget(ws.id);
1020
- parkedPrompts.forgetDelivered(sessionId, text);
1021
- return json(req, res, 200, result);
1022
- }
1023
- if (lockBlocked(result.error)) {
1024
- // Settings (if any) already stuck, so the entry parks without them.
1025
- const queued = parkedPrompts.park(ws.id, sessionId, text);
1026
- return json(req, res, 202, {
1027
- ok: false,
1028
- parked: true,
1029
- queued,
1030
- strategy: result.strategy,
1031
- error: PARKED_ERROR
1032
- });
1033
- }
1034
- return json(req, res, 502, result);
1028
+ // Retries live inside deliverPrompt, confirmed against the transcript each time,
1029
+ // and inside the deadline this phone told us it would wait.
1030
+ const result = await deliverPrompt(ws, sessionId, text, deadline - Date.now());
1031
+ if (result.ok) {
1032
+ // Whatever a queue was still holding has now been said by hand — the first
1033
+ // prompt (including a failed entry retried from the chat), and any parked
1034
+ // copy of this exact text, which delivering again would double.
1035
+ firstPrompts.forget(ws.id);
1036
+ parkedPrompts.forgetDelivered(sessionId, text);
1037
+ return { status: 200, body: result };
1038
+ }
1039
+ if (lockBlocked(result.error)) {
1040
+ // Settings (if any) already stuck, so the entry parks without them.
1041
+ const queued = parkedPrompts.park(ws.id, sessionId, text);
1042
+ return {
1043
+ status: 202,
1044
+ body: { ok: false, parked: true, queued, strategy: result.strategy, error: PARKED_ERROR }
1045
+ };
1046
+ }
1047
+ return { status: 502, body: result };
1048
+ });
1049
+ return json(req, res, answer.status, answer.body);
1035
1050
  }
1036
1051
  // POST /api/sessions/:id/split { prompt?, includeThinking?, includeTools? }
1037
1052
  //
@@ -50,6 +50,14 @@ export function workspaceTitle(w) {
50
50
  export function queryTokens(raw) {
51
51
  return raw.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? [];
52
52
  }
53
+ /**
54
+ * A temporary `NEW` marker in Conductor's picker is a badge, not part of the
55
+ * model name. The relay and phone use this value for the visible label and for
56
+ * a later selection, so both sides must remove it in the same way.
57
+ */
58
+ export function modelPickerLabel(label) {
59
+ return label.endsWith(' NEW') ? label.slice(0, -4) : label;
60
+ }
53
61
  /**
54
62
  * Markers the relay wraps search hits in (src/search.ts, via FTS5 `snippet()`). They
55
63
  * are control characters, so they must never reach the DOM: an unsplit snippet renders
@@ -3,6 +3,7 @@ import { execFile } from 'node:child_process';
3
3
  import { readFileSync } from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { promisify } from 'node:util';
6
+ import { modelPickerLabel } from "./shared.js";
6
7
  import { sidecarAvailable, sidecarSendUserMessage } from "./sidecar.js";
7
8
  const exec = promisify(execFile);
8
9
  /** Waiting runs past this are refused rather than queued. */
@@ -584,8 +585,9 @@ return my listModels()`.trim();
584
585
  const models = stdout
585
586
  .split('\n')
586
587
  .map(s => s.trim())
588
+ .map(modelPickerLabel)
587
589
  .filter(Boolean);
588
- return { ok: true, models };
590
+ return { ok: true, models: [...new Set(models)] };
589
591
  }
590
592
  catch (err) {
591
593
  return { ok: false, error: osaError(err) };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.46.0",
3
+ "version": "1.46.2",
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.",
@@ -50,13 +50,16 @@
50
50
  "typecheck": "tsc -p tsconfig.json",
51
51
  "lint": "biome check .",
52
52
  "fix": "biome check . --fix",
53
- "verify": "yarn typecheck && yarn lint && yarn check:imports && yarn check:routes && yarn check:attachments && yarn check:applescript && yarn check:nosleep && yarn check:uilock && yarn check:firstprompt",
53
+ "verify": "yarn typecheck && yarn lint && yarn check:imports && yarn check:routes && yarn check:attachments && yarn check:applescript && yarn check:model-labels && yarn check:nosleep && yarn check:uilock && yarn check:firstprompt && yarn check:sendonce && yarn check:notify",
54
54
  "release": "semantic-release",
55
55
  "prepack": "yarn build && yarn build:node",
56
56
  "postinstall": "husky || true",
57
57
  "check:attachments": "node scripts/check-attachments.ts",
58
58
  "check:applescript": "node scripts/check-applescript.ts",
59
59
  "check:firstprompt": "node scripts/check-firstprompt.ts",
60
+ "check:model-labels": "node scripts/check-model-labels.ts",
61
+ "check:sendonce": "node scripts/check-sendonce.ts",
62
+ "check:notify": "node scripts/check-notify.ts",
60
63
  "check:imports": "node scripts/check-imports.ts",
61
64
  "check:nosleep": "node scripts/check-nosleep.ts",
62
65
  "check:routes": "node scripts/check-routes.ts",