conductor-remote 1.73.1 → 1.74.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-DPr3MT68.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-ClLU5Apw.js"></script>
17
17
  <link rel="stylesheet" crossorigin href="/assets/index-CXd-zI4t.css">
18
18
  <link rel="manifest" href="/manifest.webmanifest"></head>
19
19
  <body>
package/dist/push-sw.js CHANGED
@@ -6,14 +6,22 @@
6
6
  // as a static asset, never bundled. Being part of the precache manifest is what makes a
7
7
  // change here produce a new `sw.js`, so edits actually ship.
8
8
  //
9
- // Two rules this obeys, both learned from iOS:
9
+ // Four rules this obeys, every one of them learned from iOS:
10
10
  // 1. **Every push shows a notification.** Safari treats a push that resolves without one
11
11
  // as abuse and can revoke the subscription, so there is no "suppress if the app is
12
12
  // open" branch — the relay only sends when something genuinely happened.
13
- // 2. **A tap focuses the existing window** rather than navigating it. The app is a
13
+ // 2. **A tap prefers focusing the existing window** over navigating it. The app is a
14
14
  // token-gated SPA; `openWindow` on a live client would remount the whole thing and
15
15
  // throw away in-progress composer text, so we focus and post a route instead.
16
- // 3. **The route is also parked in Cache Storage**, because on iOS neither of the two
16
+ // 3. **A focus that didn't land still owes the tap a window.** Nothing here happens by
17
+ // default: a notification click fires this handler and the app comes forward only
18
+ // because the handler asks it to. iOS returns a backgrounded home-screen web app as
19
+ // a live window client whose `focus()` can settle without foregrounding it, so
20
+ // treating "we found a client" as success ended the tap with the phone exactly where
21
+ // it was — a notification that ignores your finger. `focusClient` therefore reports
22
+ // whether the app really came up, and `openWindow` is the fallback rather than the
23
+ // branch for an app that isn't running.
24
+ // 4. **The route is also parked in Cache Storage**, because on iOS neither of the two
17
25
  // direct routes survives. A backgrounded home-screen web app is resumed on whatever
18
26
  // screen it was left on — `openWindow`'s path is ignored, and a `postMessage` to a
19
27
  // frozen page is dropped (WebKit, reported from iOS 17.1 through 18.x and still
@@ -33,6 +41,43 @@ async function parkRoute(url) {
33
41
  }
34
42
  }
35
43
 
44
+ /**
45
+ * Is this client one of ours? Parsed in a `try`, because an unparseable client URL
46
+ * throwing here would take the whole handler down and `openWindow` with it — the tap
47
+ * would then do nothing, which is the failure this file is trying to end.
48
+ */
49
+ function sameOrigin(clientUrl) {
50
+ try {
51
+ return new URL(clientUrl).origin === self.location.origin
52
+ } catch {
53
+ return false
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Bring an already-open app window to the front, and say whether it really came.
59
+ *
60
+ * That answer is the whole point. A notification click has no default action — the app
61
+ * comes forward only because this handler asks it to — so a `focus()` that quietly does
62
+ * nothing ends the tap with the phone exactly where it was, which reads as a
63
+ * notification that ignores your finger. iOS hands back a backgrounded home-screen web
64
+ * app as a live window client, and `focus()` on it can settle without foregrounding
65
+ * anything, so "we found a client" is not "the app is up".
66
+ *
67
+ * A refusal, a resolve with nothing, and a client that reports itself unfocused all
68
+ * count as "no". Answering "no" too readily costs at worst a second `openWindow` on a
69
+ * platform that had already handled the tap; answering "yes" too readily costs the tap.
70
+ */
71
+ async function focusClient(client) {
72
+ try {
73
+ const focused = await client.focus()
74
+ return !!focused && focused.focused !== false
75
+ } catch {
76
+ // Refused (some platforms want a user activation this event doesn't carry).
77
+ return false
78
+ }
79
+ }
80
+
36
81
  self.addEventListener('push', event => {
37
82
  const fallback = { title: 'Conductor Remote', body: 'Something changed in a workspace.', url: '/', tag: 'conductor' }
38
83
  let data = fallback
@@ -70,19 +115,18 @@ self.addEventListener('notificationclick', event => {
70
115
  await parkRoute(url)
71
116
  const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true })
72
117
  for (const client of clients) {
73
- if (new URL(client.url).origin !== self.location.origin) continue
118
+ if (!sameOrigin(client.url)) continue
74
119
  // Handled in web/src/hooks.ts (usePushRouting) — an in-app route change, so the
75
120
  // token gate and React state survive the tap. Posted before the focus, since a
76
121
  // refused focus is no reason to skip a message the page may well receive.
77
122
  client.postMessage({ type: 'push-navigate', url })
78
- try {
79
- await client.focus()
80
- } catch {
81
- // focus() can be refused (no user activation on some platforms); on iOS the
82
- // system foregrounds the web app on the tap regardless.
83
- }
84
- return
123
+ if (await focusClient(client)) return
124
+ break
85
125
  }
126
+ // Nothing is open, or the focus above never landed. `openWindow` is the only lever
127
+ // left that can put the app on screen, and on an installed iOS web app it launches
128
+ // or resumes the one instance rather than adding a second — the path is what it
129
+ // drops, and the parked route above is what covers that.
86
130
  await self.clients.openWindow(url)
87
131
  })()
88
132
  )
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:"aa32d6e097f4962393071bf82c472948"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-DPr3MT68.js",revision:null},{url:"assets/index-CXd-zI4t.css",revision:null},{url:"apple-touch-icon.png",revision:"1127bb396b4648add53dce3f22c92aee"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a9b0d962686287452216492cd2247499"},{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:"e7ef44deca46c0539e6ff7bba5eb815e"},{url:"index.html",revision:"dc47e6260f528397ea67bd05c73698d4"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-ClLU5Apw.js",revision:null},{url:"assets/index-CXd-zI4t.css",revision:null},{url:"apple-touch-icon.png",revision:"1127bb396b4648add53dce3f22c92aee"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a9b0d962686287452216492cd2247499"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
@@ -43,6 +43,14 @@ const TTL_SECONDS = 3600;
43
43
  const BODY_CHARS = 180;
44
44
  /** Consecutive failures before a device is dropped. A `gone` response drops it immediately, regardless. */
45
45
  const MAX_FAILURES = 20;
46
+ /**
47
+ * How long a "this device is reading that chat" stamp counts for. The stamp is refreshed
48
+ * by the transcript poll, which runs once a second, so anything past a few seconds means
49
+ * the phone stopped polling: the app was closed, the screen went away, iOS suspended it.
50
+ * Ten seconds is short enough that a phone put down mid-turn gets its notification, and
51
+ * long enough to survive a slow tunnel dropping a handful of ticks.
52
+ */
53
+ const VIEWING_FRESH_MS = 10_000;
46
54
  const clip = (s, n) => (s.length > n ? `${s.slice(0, n).trimEnd()}…` : s);
47
55
  /** Alongside the token and the first-prompt queue — one dir holding everything this relay persists. */
48
56
  function storePath() {
@@ -97,6 +105,36 @@ function save() {
97
105
  function deviceId(endpoint) {
98
106
  return crypto.createHash('sha256').update(endpoint).digest('hex').slice(0, 16);
99
107
  }
108
+ /**
109
+ * The chat each device last had on screen. Deliberately *not* part of `Store`: it is
110
+ * stamped by the transcript poll once a second, so persisting it would rewrite
111
+ * `push.json` at that rate, and a stamp is worthless the moment the relay restarts.
112
+ */
113
+ const viewing = new Map();
114
+ /**
115
+ * Record that a device is reading a chat right now. The phone sends this along its
116
+ * transcript poll only while the page is visible — the same test that moves its read
117
+ * mark — so "reading" here means on screen, not merely left open in the background.
118
+ */
119
+ export function noteViewing(id, sessionId) {
120
+ const now = Date.now();
121
+ // The id is whatever the header carried, so nothing here bounds the key space but the
122
+ // token on the request. A device that stopped polling is already ignored; drop it too,
123
+ // rather than keep a row per id anybody ever sent.
124
+ if (viewing.size > 16)
125
+ for (const [key, seen] of viewing)
126
+ if (now - seen.at >= VIEWING_FRESH_MS)
127
+ viewing.delete(key);
128
+ viewing.set(id, { sessionId, at: now });
129
+ }
130
+ /**
131
+ * Is this device looking at this chat? Suppression is per device on purpose: the phone
132
+ * in a pocket still buzzes for a chat the tablet happens to be showing.
133
+ */
134
+ export function isReading(id, sessionId, now = Date.now()) {
135
+ const seen = viewing.get(id);
136
+ return !!seen && seen.sessionId === sessionId && now - seen.at < VIEWING_FRESH_MS;
137
+ }
100
138
  function info(d) {
101
139
  return {
102
140
  id: deviceId(d.endpoint),
@@ -155,6 +193,7 @@ export function unsubscribeDevice(endpoint) {
155
193
  s.devices = s.devices.filter(d => d.endpoint !== endpoint);
156
194
  if (s.devices.length === before)
157
195
  return false;
196
+ viewing.delete(deviceId(endpoint));
158
197
  console.info(`[push] device unsubscribed — ${s.devices.length} left`);
159
198
  save();
160
199
  return true;
@@ -162,6 +201,7 @@ export function unsubscribeDevice(endpoint) {
162
201
  function drop(endpoint, why) {
163
202
  const s = load();
164
203
  s.devices = s.devices.filter(d => d.endpoint !== endpoint);
204
+ viewing.delete(deviceId(endpoint));
165
205
  console.info(`[push] dropped a dead subscription (${why}) — ${s.devices.length} left`);
166
206
  save();
167
207
  }
@@ -197,13 +237,29 @@ async function deliver(device, message) {
197
237
  save();
198
238
  return false;
199
239
  }
200
- /** Fan a message out to every subscribed device. Returns how many took it. */
201
- export async function notifyAll(message) {
240
+ /**
241
+ * Fan a message out to every subscribed device. Returns how many took it.
242
+ *
243
+ * `unlessReading` names a chat: a device with that chat on screen is skipped, because
244
+ * buzzing about a message already in front of someone is noise. The drop has to happen
245
+ * here rather than in the service worker — Safari treats a push that resolves without a
246
+ * notification as abuse and can revoke the subscription (see public/push-sw.js), so the
247
+ * phone's only way to stay quiet is for nothing to be sent.
248
+ */
249
+ export async function notifyAll(message, unlessReading) {
202
250
  const s = load();
203
251
  if (!s.devices.length)
204
252
  return 0;
205
253
  // Snapshot: `deliver` can prune the live array mid-flight.
206
- const results = await Promise.all(s.devices.slice().map(d => deliver(d, message)));
254
+ const targets = s.devices.slice().filter(d => !(unlessReading && isReading(deviceId(d.endpoint), unlessReading)));
255
+ const held = s.devices.length - targets.length;
256
+ // "Nothing arrived" and "nothing was sent, on purpose" are the same silence on a
257
+ // phone, and only one of them is a fault. /api/logs is where that question is asked.
258
+ if (held)
259
+ console.info(`[push] held back from ${held} device${held === 1 ? '' : 's'} reading that chat`);
260
+ if (!targets.length)
261
+ return 0;
262
+ const results = await Promise.all(targets.map(d => deliver(d, message)));
207
263
  return results.filter(Boolean).length;
208
264
  }
209
265
  /** Send to one device — the Connect sheet's "Send test", which proves the whole path end to end. */
@@ -386,7 +442,7 @@ async function fire(reads, sessionId, kind, state) {
386
442
  url: chatRoute(state.workspaceId, sessionId),
387
443
  kind,
388
444
  ts: Date.now()
389
- });
445
+ }, sessionId);
390
446
  if (sent)
391
447
  console.info(`[push] ${kind} in ${where} → ${sent} device${sent === 1 ? '' : 's'}`);
392
448
  }
@@ -18,7 +18,7 @@ import { createTools, handleRpc, READ_TIMEOUT_MS } from "./mcp-tools.js";
18
18
  import { mergePr } from "./merge.js";
19
19
  import { ModelCache } from "./model-cache.js";
20
20
  import { armNoSleep, disarmNoSleep, MAX_SECONDS as NOSLEEP_MAX_SECONDS, nosleepState, watchNoSleepExpiry } from "./nosleep.js";
21
- import { chatRoute, notifyAll, notifyDevice, pushConfig, startNotifier, subscribeDevice, unsubscribeDevice } from "./notify.js";
21
+ import { chatRoute, noteViewing, notifyAll, notifyDevice, pushConfig, startNotifier, subscribeDevice, unsubscribeDevice } from "./notify.js";
22
22
  import { ParkedPromptQueue } from "./parked.js";
23
23
  import { attachPrStatus } from "./pr.js";
24
24
  import { readPrefs, writePrefs } from "./prefs.js";
@@ -27,7 +27,7 @@ import { isRoute, routeParam, routes } from "./routes.js";
27
27
  import { foldHits, queryTokens, SearchIndex } from "./search.js";
28
28
  import { SendOnce } from "./sendonce.js";
29
29
  import { readSettings, writeSettings } from "./settings.js";
30
- import { withoutWindowEvidence } from "./shared.js";
30
+ import { VIEWING_HEADER, withoutWindowEvidence } from "./shared.js";
31
31
  import { discardStagedAttachment, materializeStagedAttachments, stageAttachment, stagedAttachments } from "./staged-attachments.js";
32
32
  import { driftWarningLines, readExposeMode, tailscaleBin } from "./tailscale.js";
33
33
  import { renderTranscript, transcriptThrough } from "./transcript.js";
@@ -1333,6 +1333,13 @@ const server = http.createServer(async (req, res) => {
1333
1333
  // GET /api/sessions/:id/messages?after=<rowid>
1334
1334
  const messagesOf = routeParam(routes.messages, req.method, pathname);
1335
1335
  if (messagesOf) {
1336
+ // The phone's 1s transcript poll doubles as its "I am reading this chat" heartbeat,
1337
+ // which is what keeps a turn ending on screen from also buzzing the lock screen
1338
+ // (src/notify.ts). Only this route is a claim: it is the one read that runs for the
1339
+ // chat on screen and for no other.
1340
+ const device = req.headers[VIEWING_HEADER];
1341
+ if (typeof device === 'string' && device)
1342
+ noteViewing(device, messagesOf);
1336
1343
  const after = Number(url.searchParams.get('after') ?? 0);
1337
1344
  return json(req, res, 200, reads.getMessages(messagesOf, Number.isFinite(after) ? after : 0));
1338
1345
  }
@@ -247,3 +247,14 @@ export function isLockedError(error) {
247
247
  export function withoutWindowEvidence(error) {
248
248
  return error.replace(/\s*\[window server:.*$/s, '').trim();
249
249
  }
250
+ /**
251
+ * Header naming the push device that sent a request, so the relay can tell which chat
252
+ * that device has on screen and skip notifying it about that one chat (src/notify.ts).
253
+ *
254
+ * It rides the transcript poll, which is already a per-second heartbeat for exactly the
255
+ * chat being read and for no other, so this costs no request and no timer. Declared here
256
+ * rather than spelled twice because a typo would be silent in both directions: the relay
257
+ * would simply never learn what is on screen, and every notification would keep arriving
258
+ * as it does today.
259
+ */
260
+ export const VIEWING_HEADER = 'x-relay-device';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.73.1",
3
+ "version": "1.74.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.",