conductor-remote 1.67.0 → 1.68.1

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-P1erC-f5.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-BqCdHovG.js"></script>
17
17
  <link rel="stylesheet" crossorigin href="/assets/index-YPRuGHP4.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:"ec17857cd8f5147616d1519402604e64"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-YPRuGHP4.css",revision:null},{url:"assets/index-P1erC-f5.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 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:"82beeb823118f5808af7be9a2f306f5d"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-YPRuGHP4.css",revision:null},{url:"assets/index-BqCdHovG.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\//]}))});
@@ -340,7 +340,7 @@ on activateConductor()
340
340
  -- retry loop polls this cheaply until its deadline, so an unlock mid-budget
341
341
  -- lands the send by itself — that is the observed recovery, not a theory.
342
342
  if (item 1 of firstProbe) < 1 and my screenLocked() is "locked" then
343
- error "The Mac is locked - the lock screen hides Conductor from the relay, so a send can't reach it. Unlock the Mac and send again." & my windowEvidence()
343
+ error "The Mac is locked - the lock screen hides Conductor from the relay, so nothing can be sent or pressed. Unlock the Mac and try again." & my windowEvidence()
344
344
  end if
345
345
  try
346
346
  tell application "Conductor" to activate
@@ -379,14 +379,14 @@ on activateConductor()
379
379
  -- window server can tell the difference, so a window it CAN see blocks the
380
380
  -- restart — this is a human's problem, said in words, not the relay's.
381
381
  if my serverWindowCount() > 0 then
382
- if lockState is "locked" then error "The Mac is locked - Conductor's window is open behind the lock screen, where the relay can't reach it. Unlock the Mac and send again." & ev
382
+ if lockState is "locked" then error "The Mac is locked - Conductor's window is open behind the lock screen, where the relay can't reach it. Unlock the Mac and try again." & ev
383
383
  error "Conductor's window exists but is not reachable - it is probably full screen on another Space. Take Conductor out of full screen, then send again." & ev
384
384
  end if
385
385
  -- No window anywhere and the lock screen up: don't restart. Every relaunch
386
386
  -- fired behind the lock screen in the live incident came up windowless, and
387
387
  -- the last one came up wedged — answering deep links, hanging every AX
388
388
  -- read. A restart here is not a lever, it is how the wreckage happened.
389
- if lockState is "locked" then error "The Mac is locked and Conductor has no window. Relaunching behind the lock screen is what leaves it wedged, so the relay won't - unlock the Mac and send again." & ev
389
+ if lockState is "locked" then error "The Mac is locked and Conductor has no window. Relaunching behind the lock screen is what leaves it wedged, so the relay won't - unlock the Mac and try again." & ev
390
390
  set restartError to my restartConductor()
391
391
  if restartError is not "" then error restartError & ev
392
392
  error "Conductor was running with no window and ignored both reopen and a Dock click, so the relay restarted it. Give it a few seconds and send again." & ev
@@ -430,39 +430,243 @@ on sidebarLinks()
430
430
  end tell
431
431
  end sidebarLinks
432
432
 
433
- on findSidebarRow()
434
- -- The workspace's own sidebar row. Only rows that are actually rendered exist
435
- -- in the AX tree (a collapsed section has none), and the title follows
436
- -- Conductor's precedence, so we try each candidate and require a *unique* hit —
437
- -- anything ambiguous returns nothing rather than guessing at a neighbour.
433
+ on findSidebarRowOnce()
438
434
  set titles to my targetSidebarTitles()
439
435
  if (count of titles) is 0 then return missing value
440
436
  set repoName to system attribute "RELAY_WS_REPO"
441
- set rows to my sidebarLinks()
442
- -- Every name once, ahead of the candidates, rather than once per candidate: each
443
- -- is its own round trip, and there are up to four candidates over 45 rows. It is
444
- -- also one snapshot, so a row that renames between candidates can't match twice.
445
- -- Guarded read: the sidebar re-renders constantly (every commit changes a row's
446
- -- "+adds -dels"), and one row going stale mid-scan must not take the search down.
447
- set rowNames to {}
448
- repeat with entry in rows
449
- set end of rowNames to my axName(contents of entry)
450
- end repeat
437
+ set pair to my sidebarRowsAndNames()
438
+ set refs to item 1 of pair
439
+ set nms to item 2 of pair
440
+ set total to count of refs
441
+ if total is 0 or (count of nms) is not total then return missing value
451
442
  repeat with candidate in titles
452
- if (candidate as text) is not "" then
453
- set matches to {}
454
- repeat with i from 1 to (count of rows)
455
- set rowName to item i of rowNames
456
- if rowName contains (candidate as text) then
457
- if repoName is "" or rowName contains repoName then set end of matches to item i of rows
443
+ set needle to candidate as text
444
+ if needle is not "" then
445
+ set hits to {}
446
+ repeat with i from 1 to total
447
+ set rowName to my textOrEmpty(item i of nms)
448
+ if rowName contains needle then
449
+ if repoName is "" or rowName contains repoName then set end of hits to i
458
450
  end if
459
451
  end repeat
460
- if (count of matches) is 1 then return item 1 of matches
452
+ if (count of hits) is 1 then
453
+ set idx to item 1 of hits
454
+ set hit to item idx of refs
455
+ -- The references and the names are two reads, so a sidebar that
456
+ -- re-rendered between them (every commit rewrites a row's
457
+ -- "+adds -dels") would have shifted the index under us — and pressing
458
+ -- the neighbour sets the wrong workspace's status, which is the one
459
+ -- failure this whole file is built to refuse. Confirm the element
460
+ -- still carries the name it was picked for.
461
+ if (my axName(hit)) is (my textOrEmpty(item idx of nms)) then return hit
462
+ return missing value
463
+ end if
461
464
  end if
462
465
  end repeat
463
466
  return missing value
467
+ end findSidebarRowOnce
468
+
469
+ on findSidebarRow()
470
+ -- The workspace's own sidebar row. Only rows that are actually rendered exist
471
+ -- in the AX tree (a collapsed section has none — expandSections is what opens
472
+ -- those), and the title follows Conductor's precedence, so we try each
473
+ -- candidate and require a *unique* hit — anything ambiguous returns nothing
474
+ -- rather than guessing at a neighbour. Two passes because the only thing that
475
+ -- fails the check inside is a sidebar that moved mid-read, and that is worth
476
+ -- one cheap retry.
477
+ set hit to my findSidebarRowOnce()
478
+ if hit is not missing value then return hit
479
+ return my findSidebarRowOnce()
464
480
  end findSidebarRow
465
481
 
482
+ on textOrEmpty(v)
483
+ try
484
+ if v is missing value then return ""
485
+ return v as text
486
+ on error
487
+ return ""
488
+ end try
489
+ end textOrEmpty
490
+
491
+ on childRoles(el)
492
+ -- Every child's role in *one* Apple event. Every AX read here is a round trip
493
+ -- to another process, so asking element by element is what turned a sidebar
494
+ -- scan into 15 seconds — more, by itself, than this write's whole budget.
495
+ tell application "System Events" to tell process "Conductor"
496
+ try
497
+ return role of (UI elements of el)
498
+ on error
499
+ return {}
500
+ end try
501
+ end tell
502
+ end childRoles
503
+
504
+ on childRolesAndNames(el)
505
+ tell application "System Events" to tell process "Conductor"
506
+ try
507
+ return {role of (UI elements of el), name of (UI elements of el)}
508
+ on error
509
+ return {{}, {}}
510
+ end try
511
+ end tell
512
+ end childRolesAndNames
513
+
514
+ on looksLikeSidebarList(el)
515
+ -- The sidebar's workspace list is one flat run of section headers
516
+ -- (AXStaticText) and workspace rows (AXLink), with nothing else beside them.
517
+ -- That shape is the whole test, because the *transcript* hangs off the same
518
+ -- web area and holds AXLinks of its own — "the element with links in it"
519
+ -- would happily hand back a message bubble, and every press below would then
520
+ -- land in one.
521
+ set roleList to my childRoles(el)
522
+ if (count of roleList) is 0 then return false
523
+ set seenLink to false
524
+ repeat with r in roleList
525
+ set rt to my textOrEmpty(contents of r)
526
+ if rt is "AXLink" then
527
+ set seenLink to true
528
+ else if rt is not "AXStaticText" then
529
+ return false
530
+ end if
531
+ end repeat
532
+ return seenLink
533
+ end looksLikeSidebarList
534
+
535
+ on sidebarList()
536
+ -- Found by that shape rather than by an index, since the chrome above it
537
+ -- (project pickers, the resource-usage button) comes and goes. Two levels
538
+ -- deep, the same reach sidebarLinks() uses.
539
+ set wa to my webArea()
540
+ repeat with a in (my axKids(wa))
541
+ set nodeA to contents of a
542
+ if my looksLikeSidebarList(nodeA) then return nodeA
543
+ repeat with b in (my axKids(nodeA))
544
+ set nodeB to contents of b
545
+ if my looksLikeSidebarList(nodeB) then return nodeB
546
+ end repeat
547
+ end repeat
548
+ return missing value
549
+ end sidebarList
550
+
551
+ on sidebarRowsAndNames()
552
+ -- Every sidebar row paired with its name. Two Apple events off the list found
553
+ -- above, against one per row before.
554
+ set listEl to my sidebarList()
555
+ if listEl is not missing value then
556
+ tell application "System Events" to tell process "Conductor"
557
+ try
558
+ return {(UI elements of listEl whose role is "AXLink"), name of (UI elements of listEl whose role is "AXLink")}
559
+ end try
560
+ end tell
561
+ end if
562
+ -- No list matched the shape, so Conductor has reorganised its sidebar. Fall
563
+ -- back to the broad two-level sweep and one name read per row: slow, and the
564
+ -- reason the fast path exists, but it assumes nothing about the layout.
565
+ set refs to my sidebarLinks()
566
+ set nms to {}
567
+ repeat with entry in refs
568
+ set end of nms to my axName(contents of entry)
569
+ end repeat
570
+ return {refs, nms}
571
+ end sidebarRowsAndNames
572
+
573
+ on looksNumeric(t)
574
+ -- A folded section draws its row count where its rows would be. That number is
575
+ -- a sibling of the header, not a child of it, so it has to be told apart from a
576
+ -- header by hand — digits only, which no status or repo name is.
577
+ if t is "" then return false
578
+ repeat with c in (characters of t)
579
+ if "0123456789" does not contain (c as text) then return false
580
+ end repeat
581
+ return true
582
+ end looksNumeric
583
+
584
+ on collapsedSectionNames()
585
+ -- The sections someone has folded shut. A section's rows are *siblings* of
586
+ -- its header, so an expanded one is followed by an AXLink and a collapsed one
587
+ -- by its count, by the next header, or by nothing — and a collapsed section
588
+ -- renders no rows at all, which is why its workspaces are invisible to
589
+ -- Accessibility. Names, not element references: everything below re-finds the
590
+ -- header when it needs it, because the list re-renders under us (a status
591
+ -- change moves a row to another group) and a System Events reference is an
592
+ -- index into a tree that has already changed.
593
+ set listEl to my sidebarList()
594
+ if listEl is missing value then return {}
595
+ set pair to my childRolesAndNames(listEl)
596
+ set roleList to item 1 of pair
597
+ set nameList to item 2 of pair
598
+ set total to count of roleList
599
+ if total is 0 or (count of nameList) is not total then return {}
600
+ set out to {}
601
+ repeat with i from 1 to total
602
+ if (my textOrEmpty(item i of roleList)) is "AXStaticText" then
603
+ set nm to my textOrEmpty(item i of nameList)
604
+ if nm is not "" and not (my looksNumeric(nm)) then
605
+ set nextRole to ""
606
+ if i < total then set nextRole to my textOrEmpty(item (i + 1) of roleList)
607
+ if nextRole is not "AXLink" then set end of out to nm
608
+ end if
609
+ end if
610
+ end repeat
611
+ return out
612
+ end collapsedSectionNames
613
+
614
+ on sectionHeaderNamed(wanted)
615
+ -- Unique or nothing, like every other lookup here: two sections cannot share
616
+ -- a name, so an ambiguous hit means we are reading the wrong list.
617
+ set listEl to my sidebarList()
618
+ if listEl is missing value then return missing value
619
+ tell application "System Events" to tell process "Conductor"
620
+ try
621
+ set hits to (UI elements of listEl whose role is "AXStaticText" and name is wanted)
622
+ on error
623
+ return missing value
624
+ end try
625
+ end tell
626
+ if (count of hits) is 1 then return item 1 of hits
627
+ return missing value
628
+ end sectionHeaderNamed
629
+
630
+ on toggleSection(headerName)
631
+ set head to my sectionHeaderNamed(headerName)
632
+ if head is missing value then return false
633
+ tell application "System Events" to tell process "Conductor"
634
+ try
635
+ perform action "AXScrollToVisible" of head
636
+ end try
637
+ try
638
+ perform action "AXPress" of head
639
+ return true
640
+ on error
641
+ return false
642
+ end try
643
+ end tell
644
+ end toggleSection
645
+
646
+ on expandSections()
647
+ -- Open every folded section and report which ones we opened, so the caller can
648
+ -- put the sidebar back the way it found it. The header is the whole control —
649
+ -- it carries AXPress and toggles the group — so this needs no chevron, which
650
+ -- the webview does not expose anyway.
651
+ set opened to {}
652
+ repeat with nm in (my collapsedSectionNames())
653
+ if my toggleSection(nm as text) then set end of opened to (nm as text)
654
+ end repeat
655
+ if (count of opened) > 0 then delay 0.6
656
+ return opened
657
+ end expandSections
658
+
659
+ on restoreSections(names)
660
+ -- Fold back exactly what we opened. Best-effort on purpose: this runs after
661
+ -- the write has already landed, so a header that moved or vanished must not
662
+ -- turn a status change that worked into a reported failure.
663
+ repeat with nm in names
664
+ try
665
+ my toggleSection(nm as text)
666
+ end try
667
+ end repeat
668
+ end restoreSections
669
+
466
670
  on focusViaSidebar()
467
671
  -- Press that row: no keystrokes at all, so nothing can be swallowed by a
468
672
  -- focused field or fire a palette *command*. Being wrong is survivable here —
@@ -506,9 +710,10 @@ on atTargetWorkspace()
506
710
  -- "Are we already there?" — the *strict* form of the question, so it needs a
507
711
  -- branch to have been checked and not merely skipped. Worth asking first because
508
712
  -- the pane header answers in ~0.5s where reading the sidebar's rows to press one
509
- -- measured 5-7s, the single largest cost in a send: a phone sends to the same
510
- -- workspace over and over, and Conductor stays where it was left. Safe as a
511
- -- shortcut because it *is* the assertion the send makes before typing anyway.
713
+ -- measured 5-7s, and ~1s since that scan went to bulk reads still the larger
714
+ -- cost of the two: a phone sends to the same workspace over and over, and
715
+ -- Conductor stays where it was left. Safe as a shortcut because it *is* the
716
+ -- assertion the send makes before typing anyway.
512
717
  if (system attribute "RELAY_WS_BRANCH") is "" then return false
513
718
  return my paneAgrees()
514
719
  end atTargetWorkspace
@@ -1316,23 +1521,13 @@ on dismissMenus()
1316
1521
  end tell
1317
1522
  end dismissMenus
1318
1523
 
1319
- on setWorkspaceStatus()
1320
- -- Conductor has no menu-bar or palette command for this, so the only lever is
1321
- -- the sidebar row's own context menu (Mark as unread / Pin / Set status /
1322
- -- Rename / Copy link / Archive). Right-clicking the row needs no focus change,
1323
- -- so unlike a send this never disturbs which workspace is on screen.
1324
- set wanted to system attribute "RELAY_SET_STATUS"
1325
- if wanted is "" then error "no status requested"
1326
- set theRow to my findSidebarRow()
1327
- if theRow is missing value then
1328
- error "couldn't find this workspace in the sidebar — a collapsed section hides its row from Accessibility"
1329
- end if
1524
+ on pressStatusMenu(rowEl, wanted)
1330
1525
  -- Scroll it into view first. A row that exists in the AX tree but sits outside
1331
1526
  -- the sidebar's visible strip accepts AXShowMenu and draws nothing — which is
1332
1527
  -- exactly what happens right after a status change moves it to another group.
1333
1528
  tell application "System Events" to tell process "Conductor"
1334
1529
  try
1335
- perform action "AXScrollToVisible" of theRow
1530
+ perform action "AXScrollToVisible" of rowEl
1336
1531
  end try
1337
1532
  end tell
1338
1533
  delay 0.4
@@ -1341,7 +1536,7 @@ on setWorkspaceStatus()
1341
1536
  tell application "System Events" to key code 53
1342
1537
  delay 0.3
1343
1538
  tell application "System Events" to tell process "Conductor"
1344
- perform action "AXShowMenu" of theRow
1539
+ perform action "AXShowMenu" of rowEl
1345
1540
  end tell
1346
1541
  -- Depth 4, not the whole tree: the row menu is a portal near the top of the web
1347
1542
  -- area, and the *transcript* hangs off that same root — a deep sweep walks every
@@ -1381,6 +1576,36 @@ on setWorkspaceStatus()
1381
1576
  perform action "AXPress" of choice
1382
1577
  end tell
1383
1578
  delay 0.6
1579
+ end pressStatusMenu
1580
+
1581
+ on setWorkspaceStatus()
1582
+ -- Conductor has no menu-bar or palette command for this, so the only lever is
1583
+ -- the sidebar row's own context menu (Mark as unread / Pin / Set status /
1584
+ -- Rename / Copy link / Archive). Right-clicking the row needs no focus change,
1585
+ -- so unlike a send this never disturbs which workspace is on screen.
1586
+ set wanted to system attribute "RELAY_SET_STATUS"
1587
+ if wanted is "" then error "no status requested"
1588
+ set theRow to my findSidebarRow()
1589
+ set opened to {}
1590
+ if theRow is missing value then
1591
+ -- A folded section renders no rows, so the row may simply not be in the tree
1592
+ -- yet. There is no palette command to fall back to, so open the sections
1593
+ -- ourselves and look again — then fold them back, since the one thing this
1594
+ -- write promises is that the sidebar is where you left it.
1595
+ set opened to my expandSections()
1596
+ if (count of opened) > 0 then set theRow to my findSidebarRow()
1597
+ end if
1598
+ if theRow is missing value then
1599
+ my restoreSections(opened)
1600
+ error "couldn't find this workspace in the sidebar"
1601
+ end if
1602
+ try
1603
+ my pressStatusMenu(theRow, wanted)
1604
+ on error errMsg
1605
+ my restoreSections(opened)
1606
+ error errMsg
1607
+ end try
1608
+ my restoreSections(opened)
1384
1609
  end setWorkspaceStatus
1385
1610
 
1386
1611
  (* -- Instant Hotspot ----------------------------------------------------------
@@ -613,7 +613,7 @@ export function createTools(call) {
613
613
  },
614
614
  {
615
615
  name: 'set_workspace_status',
616
- description: 'Set a workspace’s status in Conductor’s sidebar (backlog, in-progress, in-review, done, canceled). Drives the real UI through the sidebar row menu, but changes nothing on screen. Fails if the sidebar section holding that row is collapsed, because a collapsed row is invisible to Accessibility and there is no fallback.',
616
+ description: 'Set a workspace’s status in Conductor’s sidebar (backlog, in-progress, in-review, done, canceled). Drives the real UI through the sidebar row menu, but changes nothing on screen. A collapsed section hides its rows from Accessibility, so folded sections are opened, used, and folded back which makes this run a few seconds longer.',
617
617
  inputSchema: {
618
618
  type: 'object',
619
619
  properties: {
@@ -27,6 +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
31
  import { discardStagedAttachment, materializeStagedAttachments, stageAttachment, stagedAttachments } from "./staged-attachments.js";
31
32
  import { driftWarningLines, readExposeMode, tailscaleBin } from "./tailscale.js";
32
33
  import { renderTranscript } from "./transcript.js";
@@ -573,6 +574,31 @@ function previewRefusal(filePath) {
573
574
  const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
574
575
  class PayloadTooLargeError extends Error {
575
576
  }
577
+ /**
578
+ * The last thing that happens to an error before it leaves the relay: the diagnostic
579
+ * tail `windowEvidence()` appends comes off, and the full text goes to the log instead.
580
+ *
581
+ * One place rather than the dozen routes that hand back a UI-write failure, for the
582
+ * same reason `redactSecrets` sits in front of every served log line: a rule applied at
583
+ * the boundary cannot be forgotten by the next route someone adds. What it prevents is
584
+ * what a tap on Fork against a locked Mac used to answer with — the sentence, then
585
+ * "[window server: 6; screen: locked] [processes: conductor=0] [menus: Apple, Conductor,
586
+ * File, ...]", in 11px red, on a phone that can do nothing with any of it. The evidence
587
+ * is still the fastest way to tell a wedged Conductor from a hidden window, so it lands
588
+ * in relay.log, which `/api/logs` serves to the same phone on request.
589
+ */
590
+ function forTheClient(body) {
591
+ if (!body || typeof body !== 'object' || Array.isArray(body))
592
+ return body;
593
+ const error = body.error;
594
+ if (typeof error !== 'string')
595
+ return body;
596
+ const clean = withoutWindowEvidence(error);
597
+ if (clean === error)
598
+ return body;
599
+ console.warn(`[relay] ${error}`);
600
+ return { ...body, error: clean };
601
+ }
576
602
  /**
577
603
  * Successful GETs are conditional + compressed to keep the phone's polling cheap.
578
604
  * `no-cache` (not `no-store`) means the browser must revalidate on every tick —
@@ -581,7 +607,7 @@ class PayloadTooLargeError extends Error {
581
607
  * ~1 KB go out gzipped. Errors and non-GETs stay unconditional `no-store`.
582
608
  */
583
609
  function json(req, res, status, body) {
584
- const payload = Buffer.from(JSON.stringify(body));
610
+ const payload = Buffer.from(JSON.stringify(forTheClient(body)));
585
611
  if (status !== 200 || req.method !== 'GET') {
586
612
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
587
613
  return void res.end(payload);
@@ -844,15 +870,21 @@ const server = http.createServer(async (req, res) => {
844
870
  // instead of asking someone to type a network name from memory on a phone keyboard.
845
871
  // `ssid` is best-effort and often null (macOS gates it behind Location Services).
846
872
  if (isRoute(routes.settings, req.method, pathname)) {
847
- // Four subprocesses, all concurrent: this is the one route that shells out more
873
+ // Five subprocesses, all concurrent: this is the one route that shells out more
848
874
  // than once, and serialising them would put the phone's polls behind the sum.
849
- const [known, current, autoJoinHotspot, nosleep] = await Promise.all([
875
+ const [known, current, autoJoinHotspot, nosleep, locked] = await Promise.all([
850
876
  preferredNetworks(),
851
877
  currentSsid(),
852
878
  // macOS's own Auto-join Hotspot setting. On "Never" the Mac won't reach for
853
879
  // your phone unprompted, which no amount of relay code can substitute for.
854
880
  autoJoinHotspotMode(),
855
- nosleepState()
881
+ nosleepState(),
882
+ // Read here rather than polled: this sheet is where someone goes when the Mac
883
+ // stopped answering, and a keep-awake window that says "automatic screen lock
884
+ // is off" beside a Mac that is locked right now reads as the relay lying. The
885
+ // assertion blocks the *idle* lock; it cannot lift one already up, and a lid
886
+ // close or a manual lock puts one up whatever it holds.
887
+ screenLocked()
856
888
  ]);
857
889
  return json(req, res, 200, {
858
890
  settings: readSettings(),
@@ -863,7 +895,8 @@ const server = http.createServer(async (req, res) => {
863
895
  likelyHotspots: known.filter(looksLikeHotspot),
864
896
  autoJoinHotspot
865
897
  },
866
- nosleep: { ...nosleep, maxSeconds: NOSLEEP_MAX_SECONDS }
898
+ nosleep: { ...nosleep, maxSeconds: NOSLEEP_MAX_SECONDS },
899
+ screenLocked: locked
867
900
  });
868
901
  }
869
902
  // PATCH /api/settings { fallbackSsids?, autoRejoin? } — merge and persist.
@@ -214,3 +214,36 @@ export function isPreviewableSource(filePath) {
214
214
  export function isToolResult(e) {
215
215
  return e.role === 'tool' && !e.tool && e.output !== undefined;
216
216
  }
217
+ /**
218
+ * The sentence every locked-Mac refusal starts with (src/conductor.applescript), and
219
+ * the one thing two sides must agree it means.
220
+ *
221
+ * The relay decides control flow on it — `lockBlocked` (src/writes.ts) parks the prompt
222
+ * rather than burning a phone's retry budget against a screen that will not answer for
223
+ * hours — and the phone decides what to draw: a link to Screen Sharing, because the
224
+ * relay will never unlock the Mac itself. Two matchers over one phrase, spelled out in
225
+ * two files, is how one of them quietly stops matching. macOS's own wording is never
226
+ * matched anywhere here; this sentence is ours, so it cannot drift under us.
227
+ */
228
+ export const MAC_LOCKED = 'The Mac is locked';
229
+ export function isLockedError(error) {
230
+ return (error ?? '').includes(MAC_LOCKED);
231
+ }
232
+ /**
233
+ * The diagnostic tail `windowEvidence()` (src/conductor.applescript) appends to every
234
+ * window and lock refusal: the window server's count, the lock state, every process
235
+ * named Conductor with its window count, and the menu bar titles.
236
+ *
237
+ * It exists to separate "genuinely windowless" from "we are addressing the wrong
238
+ * process", which is a question for the relay's log. It reached the phone as well,
239
+ * where a tap on Fork against a locked Mac answered with four lines of red text ending
240
+ * in "[menus: Apple, Conductor, File, Edit, View, Window, Help]" and nothing to act on.
241
+ * So `json()` in src/server.ts cuts it on the way out and logs the full text instead.
242
+ *
243
+ * Anchored on our own format: the whole tail is one run of bracketed groups that starts
244
+ * at "[window server:", so a single cut takes all of it and never touches a message
245
+ * that carries none.
246
+ */
247
+ export function withoutWindowEvidence(error) {
248
+ return error.replace(/\s*\[window server:.*$/s, '').trim();
249
+ }
@@ -3,7 +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
+ import { isLockedError, modelPickerLabel } from "./shared.js";
7
7
  import { sidecarAvailable, sidecarSendUserMessage } from "./sidecar.js";
8
8
  const exec = promisify(execFile);
9
9
  /** Waiting runs past this are refused rather than queued. */
@@ -145,10 +145,12 @@ export function retryWontHelp(error) {
145
145
  * warm-up cost a retry loop should burn budget on — the caller parks the prompt
146
146
  * instead (src/parked.ts) and the queue delivers it when the lock lifts. Matched
147
147
  * on the phrase every lock refusal in conductor.applescript starts with; the
148
- * words are ours, so they can't drift under us the way macOS's can.
148
+ * words are ours, so they can't drift under us the way macOS's can. The phrase
149
+ * itself lives in src/shared.ts because the phone matches it too — it is what
150
+ * puts the Screen Sharing link beside a refusal nobody else can clear.
149
151
  */
150
152
  export function lockBlocked(error) {
151
- return (error ?? '').includes('The Mac is locked');
153
+ return isLockedError(error);
152
154
  }
153
155
  /**
154
156
  * A run that ended with the prompt still sitting in Conductor's composer
@@ -596,9 +598,15 @@ export const WORKSPACE_STATUS_LABELS = {
596
598
  *
597
599
  * Unlike every other write here this one never changes what's on screen: it
598
600
  * right-clicks the workspace's *row* (AXShowMenu) and works the menu, so the
599
- * workspace you were reading stays open. It does need the row to be rendered,
600
- * which a collapsed sidebar section preventsthat case is reported in words
601
- * rather than guessed around, because there is no palette command to fall back to.
601
+ * workspace you were reading stays open. It does need the row to be rendered, and
602
+ * a collapsed sidebar section renders none so the script opens the folded
603
+ * sections itself, looks again, and folds back exactly the ones it opened. That
604
+ * costs a second sidebar scan, which is affordable only because that scan reads
605
+ * every row's name in two Apple events rather than one per row (15s → ~1s on a
606
+ * 50-workspace sidebar; see findSidebarRow). Measured end to end on 2026-09-01:
607
+ * 8.2s through a folded section, 5.4s through an open one. The budget is 35s
608
+ * anyway, because sidebarRowsAndNames falls back to the per-row reads when the
609
+ * list no longer matches its shape, and that path pays the old 15s twice.
602
610
  */
603
611
  export async function setWorkspaceStatus(workspace, status) {
604
612
  const label = WORKSPACE_STATUS_LABELS[status];
@@ -617,7 +625,7 @@ return "ok"`.trim();
617
625
  ...targetEnvironment,
618
626
  RELAY_SET_STATUS: label
619
627
  },
620
- timeout: 25000
628
+ timeout: 35000
621
629
  })));
622
630
  return { ok: true, strategy: 'applescript' };
623
631
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.67.0",
3
+ "version": "1.68.1",
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.",