conductor-remote 1.73.2 → 1.75.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-CPu66mE4.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-CF1hSnUi.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/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 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:"0233482ea1581cc335272d86ff150fde"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-CXd-zI4t.css",revision:null},{url:"assets/index-CPu66mE4.js",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 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:"e7ef44deca46c0539e6ff7bba5eb815e"},{url:"index.html",revision:"3db899836d1b08a3d8216c2a1a06e803"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-CXd-zI4t.css",revision:null},{url:"assets/index-CF1hSnUi.js",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.2",
3
+ "version": "1.75.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.",