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/assets/{index-C_IDJcQQ.js → index-Cox9xTfw.js} +28 -28
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/dist-node/src/conductor.applescript +19 -4
- package/dist-node/src/notify.js +122 -43
- package/dist-node/src/reads.js +13 -2
- package/dist-node/src/sendonce.js +109 -0
- package/dist-node/src/server.js +52 -37
- package/dist-node/src/shared.js +8 -0
- package/dist-node/src/writes.js +3 -1
- package/package.json +5 -2
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-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
|
|
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
|
|
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
|
-
--
|
|
924
|
-
-- prefix match
|
|
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
|
|
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()
|
package/dist-node/src/notify.js
CHANGED
|
@@ -235,53 +235,132 @@ function oneLine(text) {
|
|
|
235
235
|
.trim(), BODY_CHARS);
|
|
236
236
|
}
|
|
237
237
|
// --- the watcher ---
|
|
238
|
-
/**
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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);
|
package/dist-node/src/reads.js
CHANGED
|
@@ -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(
|
|
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
|
+
}
|
package/dist-node/src/server.js
CHANGED
|
@@ -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
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
//
|
package/dist-node/src/shared.js
CHANGED
|
@@ -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
|
package/dist-node/src/writes.js
CHANGED
|
@@ -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.
|
|
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",
|