conductor-remote 1.38.0 → 1.40.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-BSwT6p-R.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-BhYD1Ar2.js"></script>
17
17
  <link rel="stylesheet" crossorigin href="/assets/index-UopM0hHW.css">
18
18
  <link rel="manifest" href="/manifest.webmanifest"></head>
19
19
  <body>
package/dist/push-sw.js CHANGED
@@ -13,6 +13,25 @@
13
13
  // 2. **A tap focuses the existing window** rather than 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
17
+ // direct routes survives. A backgrounded home-screen web app is resumed on whatever
18
+ // screen it was left on — `openWindow`'s path is ignored, and a `postMessage` to a
19
+ // frozen page is dropped (WebKit, reported from iOS 17.1 through 18.x and still
20
+ // open). The cache outlives both, so the app reads its target when it comes back to
21
+ // the front; see `usePushRouting` in web/src/hooks.ts.
22
+
23
+ /** One entry, overwritten per tap: only the newest tap can still be waiting to land. */
24
+ const ROUTE_CACHE = 'push-route'
25
+ const ROUTE_KEY = '/__push-route'
26
+
27
+ async function parkRoute(url) {
28
+ try {
29
+ const cache = await caches.open(ROUTE_CACHE)
30
+ await cache.put(ROUTE_KEY, new Response(JSON.stringify({ url, ts: Date.now() })))
31
+ } catch {
32
+ // Storage refused it (quota, a private window). The two direct routes below still stand.
33
+ }
34
+ }
16
35
 
17
36
  self.addEventListener('push', event => {
18
37
  const fallback = { title: 'Conductor Remote', body: 'Something changed in a workspace.', url: '/', tag: 'conductor' }
@@ -28,8 +47,9 @@ self.addEventListener('push', event => {
28
47
  event.waitUntil(
29
48
  self.registration.showNotification(data.title, {
30
49
  body: data.body,
31
- // Tagged per workspace by the relay: a chatty agent replaces its own notification
32
- // instead of stacking. `renotify` keeps the replacement audible.
50
+ // Tagged per chat by the relay: a chatty agent replaces its own notification
51
+ // instead of stacking, while a sibling chat keeps its own (they open different
52
+ // screens). `renotify` keeps the replacement audible.
33
53
  tag: data.tag,
34
54
  renotify: true,
35
55
  icon: '/icon-192.png',
@@ -45,18 +65,23 @@ self.addEventListener('notificationclick', event => {
45
65
  const url = (event.notification.data && event.notification.data.url) || '/'
46
66
  event.waitUntil(
47
67
  (async () => {
68
+ // Park first. Everything below can succeed and still leave the phone on the wrong
69
+ // screen, and this is the copy the app reads when it wakes up.
70
+ await parkRoute(url)
48
71
  const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true })
49
72
  for (const client of clients) {
50
73
  if (new URL(client.url).origin !== self.location.origin) continue
74
+ // Handled in web/src/hooks.ts (usePushRouting) — an in-app route change, so the
75
+ // token gate and React state survive the tap. Posted before the focus, since a
76
+ // refused focus is no reason to skip a message the page may well receive.
77
+ client.postMessage({ type: 'push-navigate', url })
51
78
  try {
52
79
  await client.focus()
53
- // Handled in web/src/hooks.ts (usePushRouting) — an in-app route change,
54
- // so the token gate and React state survive the tap.
55
- client.postMessage({ type: 'push-navigate', url })
56
- return
57
80
  } catch {
58
- // focus() can be refused (no user activation on some platforms) fall through to openWindow
81
+ // focus() can be refused (no user activation on some platforms); on iOS the
82
+ // system foregrounds the web app on the tap regardless.
59
83
  }
84
+ return
60
85
  }
61
86
  await self.clients.openWindow(url)
62
87
  })()
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:"890f6a70e97d144f4d025254e90892a1"},{url:"index.html",revision:"defb157d406e6afd0bc4de8feb837657"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-UopM0hHW.css",revision:null},{url:"assets/index-BSwT6p-R.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:"1dca8269fa4358508f3ca90f0c216cdb"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-UopM0hHW.css",revision:null},{url:"assets/index-BhYD1Ar2.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\//]}))});
@@ -1154,3 +1154,158 @@ on setWorkspaceStatus()
1154
1154
  end tell
1155
1155
  delay 0.6
1156
1156
  end setWorkspaceStatus
1157
+
1158
+ (* -- Instant Hotspot ----------------------------------------------------------
1159
+ The funnel watchdog's last resort when this Mac has no route: press the row
1160
+ for a personal hotspot in Control Center's Wi-Fi popover. networksetup can
1161
+ only join a network that is broadcasting, and a personal hotspot usually
1162
+ is not - the row in the Wi-Fi menu is fed by Continuity over Bluetooth, and
1163
+ pressing it asks the phone to wake its hotspot, exactly like a human
1164
+ clicking the Wi-Fi menu. Everything here targets process "ControlCenter",
1165
+ not Conductor - same Accessibility grant, different process, which is why
1166
+ the refusal mapping below skips refusalReason's -1712 branch (its words are
1167
+ about a wedged Conductor).
1168
+ Addressed by AXIdentifier throughout ("com.apple.menuextra.wifi",
1169
+ "wifi-network-<name>", "wifi-header") - the items' names are missing value
1170
+ and any visible text is localized, the identifiers are neither (measured
1171
+ live on macOS 26.5, 2026-08). A "whose value of attribute ..." filter fails
1172
+ wholesale (-1728) when any element lacks the attribute - several menu bar
1173
+ items do - so every lookup here walks with a per-item try instead.
1174
+ The SE-terms trap at the top of CLAUDE.md's applescript section bit this
1175
+ code through a HANDLER PARAMETER: findAxId's first argument was once named
1176
+ `container`, a System Events dictionary word, so inside the tell it
1177
+ resolved as the term and the walk found nothing - through an error-eating
1178
+ try, so a provably open popover read as "no popover" for a whole debugging
1179
+ session while every direct read worked. osacompile accepts it, the handler
1180
+ check can't see it. Keep every identifier in these handlers boring and
1181
+ un-dictionary-like.
1182
+ The menu bar item is a TOGGLE: the same press opens and closes the
1183
+ popover, so nothing here presses it without first checking which state the
1184
+ popover is in (wifiPopoverWindow), and an already-open popover aborts the
1185
+ run - a human may be mid-gesture in it, and their next click kills an
1186
+ automated run anyway (contention looks exactly like the status-row menu's:
1187
+ fails while someone uses the Mac, clean when it is unattended). *)
1188
+
1189
+ on wifiMenuBarItem()
1190
+ try
1191
+ with timeout of 6 seconds
1192
+ tell application "System Events" to tell process "ControlCenter"
1193
+ repeat with mi in menu bar items of menu bar 1
1194
+ try
1195
+ if (value of attribute "AXIdentifier" of mi) is "com.apple.menuextra.wifi" then return contents of mi
1196
+ end try
1197
+ end repeat
1198
+ end tell
1199
+ end timeout
1200
+ on error errText number errNum
1201
+ if errNum is not -1712 then
1202
+ set refusal to my refusalReason({-1, errNum, errText})
1203
+ if refusal is not "" then error refusal
1204
+ end if
1205
+ error "reading the menu bar failed (" & errNum & "): " & errText
1206
+ end try
1207
+ error "the Wi-Fi control is not in the menu bar - turn on Show in Menu Bar for Wi-Fi in System Settings > Control Center"
1208
+ end wifiMenuBarItem
1209
+
1210
+ on findAxId(parentEl, wantedId, depth)
1211
+ -- Depth-capped even though the popover is small: the cap is what keeps a
1212
+ -- macOS reshuffle of this tree from turning one lookup into a full crawl.
1213
+ -- `parentEl` is deliberately not `container` - see the section comment.
1214
+ tell application "System Events"
1215
+ try
1216
+ set kids to UI elements of parentEl
1217
+ on error
1218
+ return missing value
1219
+ end try
1220
+ end tell
1221
+ repeat with el in kids
1222
+ try
1223
+ tell application "System Events"
1224
+ if (value of attribute "AXIdentifier" of el) is wantedId then return contents of el
1225
+ end tell
1226
+ end try
1227
+ if depth > 1 then
1228
+ set hit to my findAxId(el, wantedId, depth - 1)
1229
+ if hit is not missing value then return hit
1230
+ end if
1231
+ end repeat
1232
+ return missing value
1233
+ end findAxId
1234
+
1235
+ on wifiPopoverWindow()
1236
+ -- The popover is whichever ControlCenter window carries the "wifi-header"
1237
+ -- id. Existence is read fresh on every call: pressing a network row can
1238
+ -- close the popover by itself, and the menu bar item is a toggle, so a
1239
+ -- blind second press would reopen what just closed.
1240
+ tell application "System Events" to tell process "ControlCenter"
1241
+ try
1242
+ set wins to windows
1243
+ on error
1244
+ return missing value
1245
+ end try
1246
+ end tell
1247
+ repeat with w in wins
1248
+ if my findAxId(w, "wifi-header", 3) is not missing value then return contents of w
1249
+ end repeat
1250
+ return missing value
1251
+ end wifiPopoverWindow
1252
+
1253
+ on closeWifiPopover(wifiItem)
1254
+ if my wifiPopoverWindow() is not missing value then
1255
+ try
1256
+ tell application "System Events" to perform action "AXPress" of wifiItem
1257
+ end try
1258
+ end if
1259
+ end closeWifiPopover
1260
+
1261
+ on joinInstantHotspot()
1262
+ -- The name arrives via a file, like RELAY_PROMPT_FILE: do shell script
1263
+ -- output is decoded as UTF-8, so a name with non-ASCII letters survives
1264
+ -- where a system attribute read may not. AppleScript's default nonliteral
1265
+ -- text comparison then treats composed and decomposed accents as equal,
1266
+ -- which matters because the AX identifier uses whatever normalization
1267
+ -- macOS chose.
1268
+ set hotspotName to do shell script "cat " & quoted form of (system attribute "RELAY_HOTSPOT_FILE")
1269
+ if hotspotName is "" then error "no hotspot name provided"
1270
+ -- The lock screen hides the session from Accessibility, so behind it this
1271
+ -- press has nothing to land on. Named up front, in the same words the send
1272
+ -- path uses, instead of surfacing later as a popover that never opened.
1273
+ if my screenLocked() is "locked" then error "The Mac is locked - the Wi-Fi menu can't be pressed until it unlocks"
1274
+ set wifiItem to my wifiMenuBarItem()
1275
+ if my wifiPopoverWindow() is not missing value then error "the Wi-Fi popover is already open - someone may be using it, so nothing was pressed"
1276
+ tell application "System Events" to perform action "AXPress" of wifiItem
1277
+ -- The popover renders in its own time (measured 1-2s), and a human's click
1278
+ -- anywhere on the Mac closes it, so poll rather than sleep once.
1279
+ set pop to missing value
1280
+ repeat 8 times
1281
+ delay 0.5
1282
+ set pop to my wifiPopoverWindow()
1283
+ if pop is not missing value then exit repeat
1284
+ end repeat
1285
+ if pop is missing value then error "pressing the Wi-Fi control opened no popover - someone clicking on the Mac closes it the moment it opens (contention, not a fault); otherwise Control Center is not answering"
1286
+ set rowEl to missing value
1287
+ repeat 4 times
1288
+ set rowEl to my findAxId(pop, "wifi-network-" & hotspotName, 6)
1289
+ if rowEl is not missing value then exit repeat
1290
+ delay 0.5
1291
+ end repeat
1292
+ if rowEl is missing value then
1293
+ my closeWifiPopover(wifiItem)
1294
+ error quote & hotspotName & quote & " is not in the Wi-Fi menu - the iPhone is out of Bluetooth range, has Bluetooth off, or Personal Hotspot is off in its Settings"
1295
+ end if
1296
+ -- The row is a toggle too: value 1 means already connected, and pressing
1297
+ -- it then would DISCONNECT - the one outcome worse than doing nothing.
1298
+ -- Only a read of exactly 1 skips the press; an unreadable value presses.
1299
+ set rowValue to 0
1300
+ try
1301
+ tell application "System Events" to set rowValue to value of rowEl
1302
+ end try
1303
+ if rowValue is 1 then
1304
+ my closeWifiPopover(wifiItem)
1305
+ return "already-connected"
1306
+ end if
1307
+ tell application "System Events" to perform action "AXPress" of rowEl
1308
+ delay 1
1309
+ my closeWifiPopover(wifiItem)
1310
+ return "pressed"
1311
+ end joinInstantHotspot
@@ -31,6 +31,7 @@ import { promisify } from 'node:util';
31
31
  import { readSettings } from "./settings.js";
32
32
  import { magicDnsName, readExposeMode, relayPort, tailscaleBin } from "./tailscale.js";
33
33
  import { hasDefaultRoute, joinNetwork, preferredNetworks } from "./wifi.js";
34
+ import { joinInstantHotspot } from "./writes.js";
34
35
  const execFileP = promisify(execFile);
35
36
  const PROBE_PATH = '/health'; // unauthenticated 200 on the relay — no token needed to prove reachability
36
37
  const PROBE_TIMEOUT_MS = 8000;
@@ -157,6 +158,19 @@ let lastHealAt = 0;
157
158
  let lastRejoinAt = 0;
158
159
  const REJOIN_COOLDOWN_MS = 5 * 60 * 1000; // a network switch is disruptive; never churn on one
159
160
  const REJOIN_SETTLE_MS = 12 * 1000; // DHCP + tailscaled noticing the new endpoint
161
+ // An Instant Hotspot press has the phone's Bluetooth wake + hotspot spin-up in front of the
162
+ // same DHCP wait, so it gets a longer leash than a plain join before the tick gives up on it.
163
+ const HOTSPOT_SETTLE_MS = 30 * 1000;
164
+ /** Poll for the default route instead of one fixed sleep — a join that lands early returns early. */
165
+ async function routeAppeared(waitMs) {
166
+ const until = Date.now() + waitMs;
167
+ while (Date.now() < until) {
168
+ if (await hasDefaultRoute())
169
+ return true;
170
+ await new Promise(r => setTimeout(r, 3000));
171
+ }
172
+ return hasDefaultRoute();
173
+ }
160
174
  /**
161
175
  * Last resort when the probe is down: this Mac has no link at all, so move it onto a
162
176
  * configured fallback (your phone's hotspot) and re-register Funnel, whose ingress a
@@ -173,6 +187,14 @@ const REJOIN_SETTLE_MS = 12 * 1000; // DHCP + tailscaled noticing the new endpoi
173
187
  * or passed; an SSID that isn't in the preferred list is named in the log, not tried.
174
188
  * - Behind a cooldown, so a Mac that is simply off the air doesn't cycle its Wi-Fi.
175
189
  *
190
+ * Two ways in, tried in order. `networksetup` first — cheap, no UI — and when it answers
191
+ * "Could not find network" (a hotspot doesn't broadcast until asked), the Accessibility
192
+ * press on the Wi-Fi menu's own row (`joinInstantHotspot` in writes.ts), which wakes the
193
+ * phone's hotspot over Continuity exactly like clicking it. The press needs an unlocked
194
+ * screen — the lock hides the session from Accessibility, and the failure names that —
195
+ * so a lid-closed Mac still wants macOS's own Auto-Join Hotspot set to Automatic as the
196
+ * layer below this one.
197
+ *
176
198
  * Returns true if a join reported success, meaning the caller should re-register rather
177
199
  * than treat this tick as an ordinary failure.
178
200
  */
@@ -194,17 +216,30 @@ async function tryRejoin() {
194
216
  lastRejoinAt = Date.now();
195
217
  for (const ssid of candidates) {
196
218
  log(`no default route — joining fallback network "${ssid}"`);
219
+ let settleMs = REJOIN_SETTLE_MS;
197
220
  const joined = await joinNetwork(ssid);
198
221
  if (!joined.ok) {
199
222
  log(`join "${ssid}" failed: ${joined.error}`);
200
- continue;
223
+ // networksetup can only join a network that is broadcasting, and a personal
224
+ // hotspot usually isn't — its row in the Wi-Fi menu arrives over Continuity,
225
+ // and pressing it wakes the hotspot the way clicking it by hand does. Tried
226
+ // for every failed candidate rather than only hotspot-looking names: the
227
+ // name heuristic never decides anything (see looksLikeHotspot), and for an
228
+ // ordinary network that's simply out of range the press fails in words
229
+ // ("not in the Wi-Fi menu") that cost one popover flash.
230
+ log(`pressing the Wi-Fi menu's "${ssid}" row instead (Instant Hotspot)`);
231
+ const pressed = await joinInstantHotspot(ssid);
232
+ if (!pressed.ok) {
233
+ log(`Instant Hotspot press for "${ssid}" failed: ${pressed.error}`);
234
+ continue;
235
+ }
236
+ settleMs = HOTSPOT_SETTLE_MS;
201
237
  }
202
- await new Promise(r => setTimeout(r, REJOIN_SETTLE_MS));
203
- if (await hasDefaultRoute()) {
238
+ if (await routeAppeared(settleMs)) {
204
239
  log(`joined "${ssid}" and the link is up`);
205
240
  return true;
206
241
  }
207
- log(`joined "${ssid}" but no default route appeared after ${REJOIN_SETTLE_MS / 1000}s`);
242
+ log(`joined "${ssid}" but no default route appeared after ${settleMs / 1000}s`);
208
243
  }
209
244
  return false;
210
245
  }
@@ -218,6 +218,15 @@ export async function notifyDevice(id, message) {
218
218
  export function deviceCount() {
219
219
  return load().devices.length;
220
220
  }
221
+ /**
222
+ * Where a tapped notification lands. The chat id rides along because the phone
223
+ * otherwise picks the tab itself (Conductor's active session, else the first one)
224
+ * — and on a multi-chat workspace that is rarely the chat that just finished.
225
+ * Kept here so the notifier and the parked-prompt queue can't drift apart.
226
+ */
227
+ export function chatRoute(workspaceId, sessionId) {
228
+ return `/w/${workspaceId}?session=${encodeURIComponent(sessionId)}`;
229
+ }
221
230
  /** Collapse a transcript entry to one lock-screen line: no code fences, no blank runs. */
222
231
  function oneLine(text) {
223
232
  return clip(text
@@ -287,9 +296,11 @@ async function fire(reads, sessionId, kind, state) {
287
296
  const sent = await notifyAll({
288
297
  title: state.repoName ? `${where} — ${state.repoName}` : where,
289
298
  body,
290
- // Per workspace, so a chatty agent replaces its own notification instead of stacking.
291
- tag: state.workspaceId,
292
- url: `/w/${state.workspaceId}`,
299
+ // Per chat, so a chatty agent replaces its own notification instead of stacking
300
+ // and two chats in one workspace stay separately tappable, since each now lands
301
+ // somewhere different.
302
+ tag: sessionId,
303
+ url: chatRoute(state.workspaceId, sessionId),
293
304
  kind,
294
305
  ts: Date.now()
295
306
  });
@@ -12,7 +12,7 @@ import { workspaceDiff } from "./git.js";
12
12
  import { installLogCapture, isManaged, LOG_FILE_NAMES, logFiles, processStartedAt, recentLogs, redactSecrets, tailLogFile } from "./logbuf.js";
13
13
  import { mergePr } from "./merge.js";
14
14
  import { armNoSleep, disarmNoSleep, MAX_SECONDS as NOSLEEP_MAX_SECONDS, nosleepState, watchNoSleepExpiry } from "./nosleep.js";
15
- import { notifyAll, notifyDevice, pushConfig, startNotifier, subscribeDevice, unsubscribeDevice } from "./notify.js";
15
+ import { chatRoute, notifyAll, notifyDevice, pushConfig, startNotifier, subscribeDevice, unsubscribeDevice } from "./notify.js";
16
16
  import { ParkedPromptQueue } from "./parked.js";
17
17
  import { attachPrStatus } from "./pr.js";
18
18
  import { Reads } from "./reads.js";
@@ -288,7 +288,7 @@ const parkedPrompts = new ParkedPromptQueue(path.join(stateDir(), 'parked-prompt
288
288
  body: error ? `Parked prompt failed: ${error}` : `Sent after unlock: ${preview}`,
289
289
  // Per chat, so a second parked prompt replaces the first's notification.
290
290
  tag: `parked-${entry.sessionId}`,
291
- url: `/w/${entry.workspaceId}`,
291
+ url: chatRoute(entry.workspaceId, entry.sessionId),
292
292
  kind: error ? 'error' : 'done',
293
293
  ts: Date.now()
294
294
  });
@@ -261,12 +261,12 @@ function targetEnv(target) {
261
261
  };
262
262
  }
263
263
  /** osascript echoes the whole failing script back; keep just the reason for the phone. */
264
- function osaError(err) {
264
+ function osaError(err, timeoutMsg = 'Conductor took too long to respond') {
265
265
  const raw = err instanceof Error ? err.message : String(err);
266
266
  // A timeout kill carries no execution error at all — its first line is
267
267
  // "Command failed: osascript -e" plus the whole script, which is useless here.
268
268
  if (err && typeof err === 'object' && 'killed' in err && err.killed) {
269
- return 'Conductor took too long to respond';
269
+ return timeoutMsg;
270
270
  }
271
271
  return raw.match(/execution error: (.+?) \(-?\d+\)/)?.[1] ?? raw.split('\n')[0];
272
272
  }
@@ -368,6 +368,48 @@ return "ok"`.trim();
368
368
  return { ok: false, strategy: 'applescript', error: osaError(err) };
369
369
  }
370
370
  }
371
+ /**
372
+ * Press the Wi-Fi menu's row for a personal hotspot — Instant Hotspot, the same
373
+ * button a human clicks. `networksetup` can only join a network that is
374
+ * broadcasting, and a personal hotspot usually isn't; the row in Control
375
+ * Center's Wi-Fi popover is fed by Continuity over Bluetooth, and pressing it
376
+ * asks the phone to wake its hotspot. The funnel watchdog reaches for this when
377
+ * a plain join answered "Could not find network".
378
+ *
379
+ * The one UI write here that doesn't target Conductor, and it still takes a
380
+ * uiTurn: the popover steals key focus, so a palette fallback running at the
381
+ * same moment would type into it. The name travels via a temp file like the
382
+ * prompt does — same escaping-and-encoding dodge, and hotspot names ("Han
383
+ * høyes iPhone") are non-ASCII more often than prompts are. Success here means
384
+ * *pressed*, nothing more: joining takes several seconds of Bluetooth wake +
385
+ * DHCP, so the caller owns the wait, and it watches `hasDefaultRoute()` — the
386
+ * one link signal that needs no permission — not this function's word.
387
+ * Everything else — the lock check, the toggle-aware close, the already-open
388
+ * abort, the contention story — lives with the handler in conductor.applescript.
389
+ */
390
+ export async function joinInstantHotspot(name) {
391
+ const script = `
392
+ ${CONDUCTOR_HANDLERS}
393
+
394
+ my joinInstantHotspot()`.trim();
395
+ const os = await import('node:os');
396
+ const fs = await import('node:fs/promises');
397
+ const tmp = path.join(os.tmpdir(), `relay-hotspot-${process.pid}-${Date.now()}.txt`);
398
+ await fs.writeFile(tmp, name, 'utf8');
399
+ try {
400
+ await uiTurn(() => exec('osascript', ['-e', script], {
401
+ env: { ...process.env, RELAY_HOTSPOT_FILE: tmp },
402
+ timeout: 25_000
403
+ }));
404
+ return { ok: true };
405
+ }
406
+ catch (err) {
407
+ return { ok: false, error: osaError(err, 'the Wi-Fi menu press took too long') };
408
+ }
409
+ finally {
410
+ await fs.rm(tmp, { force: true }).catch(() => undefined);
411
+ }
412
+ }
371
413
  /**
372
414
  * Conductor stores the effort level as `sessions.claude_effort_level`, but the
373
415
  * composer button is labelled with the human name and *cycles* through them in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.38.0",
3
+ "version": "1.40.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.",