conductor-remote 1.32.1 → 1.34.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,8 +13,8 @@
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-D883fUSq.js"></script>
17
- <link rel="stylesheet" crossorigin href="/assets/index-DwrzkcPG.css">
16
+ <script type="module" crossorigin src="/assets/index-jfBrvANR.js"></script>
17
+ <link rel="stylesheet" crossorigin href="/assets/index-BfvWGOfK.css">
18
18
  <link rel="manifest" href="/manifest.webmanifest"></head>
19
19
  <body>
20
20
  <div id="root"></div>
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:"890f6a70e97d144f4d025254e90892a1"},{url:"index.html",revision:"77e0c625cdd4aa58ad272304a943de7b"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-DwrzkcPG.css",revision:null},{url:"assets/index-D883fUSq.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:"890f6a70e97d144f4d025254e90892a1"},{url:"index.html",revision:"3d3a6c1f7a0b0f8cb32564e5a1970d02"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-jfBrvANR.js",revision:null},{url:"assets/index-BfvWGOfK.css",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\//]}))});
@@ -141,12 +141,27 @@ export class Reads {
141
141
  }
142
142
  listSessions(workspaceId) {
143
143
  // created_at ASC keeps tab order stable (matches the desktop app) instead of jumping on activity.
144
- return this.db.query(`SELECT id, status, title, model, permission_mode, claude_effort_level, fast_mode, agent_type,
145
- context_used_percent, unread_count,
146
- created_at, updated_at, last_user_message_at
147
- FROM sessions
148
- WHERE workspace_id = ? AND COALESCE(is_hidden, 0) = 0
149
- ORDER BY created_at ASC`, [workspaceId]);
144
+ //
145
+ // `turn_started_at` is when the current answer began, which is NOT
146
+ // `last_user_message_at`: a message typed while the agent is already working is
147
+ // *steering* — it joins the running turn rather than starting one, and the phone's
148
+ // elapsed timer must not restart on it. Conductor separates the two itself.
149
+ // `session_messages.queue_order` is set exactly on the messages that head a turn
150
+ // (verified over the whole DB from May 2026, when the column appeared — older rows
151
+ // are all NULL, so a long-dormant chat reports null here and simply shows no timer),
152
+ // and `sent_at` is when that head was dispatched, so a prompt that sat in the queue
153
+ // times from when it actually ran, not from when it was typed. Still-queued heads
154
+ // have `sent_at` NULL and are skipped, which is why a queued message can't blip the
155
+ // timer while the previous answer is still going. Served straight off
156
+ // idx_session_messages_sent_at(session_id, sent_at) — measured free at this poll rate.
157
+ return this.db.query(`SELECT s.id, s.status, s.title, s.model, s.permission_mode, s.claude_effort_level, s.fast_mode, s.agent_type,
158
+ s.context_used_percent, s.unread_count,
159
+ s.created_at, s.updated_at, s.last_user_message_at,
160
+ (SELECT MAX(m.sent_at) FROM session_messages m
161
+ WHERE m.session_id = s.id AND m.queue_order IS NOT NULL AND m.sent_at IS NOT NULL) AS turn_started_at
162
+ FROM sessions s
163
+ WHERE s.workspace_id = ? AND COALESCE(s.is_hidden, 0) = 0
164
+ ORDER BY s.created_at ASC`, [workspaceId]);
150
165
  }
151
166
  /**
152
167
  * Every live chat's status, across all workspaces — what the notifier watches for
@@ -15,7 +15,7 @@ import { notifyDevice, pushConfig, startNotifier, subscribeDevice, unsubscribeDe
15
15
  import { attachPrStatus } from "./pr.js";
16
16
  import { Reads } from "./reads.js";
17
17
  import { driftWarningLines, tailscaleBin } from "./tailscale.js";
18
- import { createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, newChat, pickActuator, retryWontHelp, setAgentOptions, setRestartGuard } from "./writes.js";
18
+ import { createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, newChat, pickActuator, retryWontHelp, setAgentOptions, setRestartGuard, setWorkspaceStatus, WORKSPACE_STATUS_LABELS } from "./writes.js";
19
19
  // Before anything that logs: from here on every console line is also kept in memory for
20
20
  // `GET /api/logs`, so the phone can read why a send failed without ssh-ing into the Mac.
21
21
  installLogCapture();
@@ -518,6 +518,43 @@ const server = http.createServer(async (req, res) => {
518
518
  const result = await mergePr(ws);
519
519
  return json(req, res, result.ok ? 200 : 409, result);
520
520
  }
521
+ // POST /api/workspaces/:id/status { status } — move it between the sidebar's status groups.
522
+ // Conductor derives that status from a PR it sometimes never links (a PR merged inside its
523
+ // poll window is invisible to it afterwards), which strands finished work in "In progress"
524
+ // with no way to correct it from a phone. This is that way.
525
+ m = pathname.match(/^\/api\/workspaces\/([^/]+)\/status$/);
526
+ if (req.method === 'POST' && m) {
527
+ const workspaceId = decodeURIComponent(m[1]);
528
+ const body = JSON.parse((await readBody(req)) || '{}');
529
+ const status = body.status ?? '';
530
+ if (!WORKSPACE_STATUS_LABELS[status]) {
531
+ const allowed = Object.keys(WORKSPACE_STATUS_LABELS).join(', ');
532
+ return json(req, res, 400, { error: `status must be one of ${allowed}` });
533
+ }
534
+ const ws = reads.getWorkspace(workspaceId);
535
+ if (!ws)
536
+ return json(req, res, 404, { error: 'workspace not found' });
537
+ const result = await setWorkspaceStatus(ws, status);
538
+ if (!result.ok)
539
+ return json(req, res, 502, result);
540
+ // The menu press lands in the DB a beat later. Confirm rather than assume —
541
+ // and if Conductor wrote something else, say what, instead of "didn't work".
542
+ let observed = ws.manual_status ?? '';
543
+ for (let i = 0; i < 10 && observed !== status; i++) {
544
+ await new Promise(r => setTimeout(r, 300));
545
+ observed = reads.getWorkspace(workspaceId)?.manual_status ?? '';
546
+ }
547
+ if (observed !== status) {
548
+ return json(req, res, 502, {
549
+ ok: false,
550
+ strategy: result.strategy,
551
+ error: observed
552
+ ? `Conductor recorded the status as “${observed}”, not “${status}”.`
553
+ : 'Conductor didn’t record the change — it may have been asleep. Try again.'
554
+ });
555
+ }
556
+ return json(req, res, 200, { ok: true, workspace: reads.getWorkspace(workspaceId) });
557
+ }
521
558
  // GET /api/sessions/:id/messages?after=<rowid>
522
559
  m = pathname.match(/^\/api\/sessions\/([^/]+)\/messages$/);
523
560
  if (req.method === 'GET' && m) {
@@ -383,34 +383,39 @@ on sidebarLinks()
383
383
  end tell
384
384
  end sidebarLinks
385
385
 
386
- on focusViaSidebar()
387
- -- Press the workspace's sidebar row: no keystrokes at all, so nothing can be
388
- -- swallowed by a focused field or fire a palette *command*. Only rows that are
389
- -- actually rendered exist in the AX tree (a collapsed section has none), and
390
- -- the title precedence is Conductor's, so we try each candidate and require a
391
- -- unique hit — anything ambiguous falls back to the palette. Being wrong is
392
- -- survivable here: assertWorkspace re-checks before we type.
386
+ on findSidebarRow()
387
+ -- The workspace's own sidebar row. Only rows that are actually rendered exist
388
+ -- in the AX tree (a collapsed section has none), and the title follows
389
+ -- Conductor's precedence, so we try each candidate and require a *unique* hit —
390
+ -- anything ambiguous returns nothing rather than guessing at a neighbour.
393
391
  set titles to my splitLines(system attribute "RELAY_WS_TITLES")
394
- if (count of titles) is 0 then return false
392
+ if (count of titles) is 0 then return missing value
395
393
  set repoName to system attribute "RELAY_WS_REPO"
396
394
  set rows to my sidebarLinks()
397
- set hit to missing value
398
395
  repeat with candidate in titles
399
396
  if (candidate as text) is not "" then
400
397
  set matches to {}
401
398
  repeat with entry in rows
402
399
  set row to contents of entry
403
- set rowName to my tabLabel(row)
400
+ -- Guarded read: the sidebar re-renders constantly (every commit
401
+ -- changes a row's "+adds -dels"), and one row that goes stale
402
+ -- mid-scan must not take the whole search down with it.
403
+ set rowName to my axName(row)
404
404
  if rowName contains (candidate as text) then
405
405
  if repoName is "" or rowName contains repoName then set end of matches to row
406
406
  end if
407
407
  end repeat
408
- if (count of matches) is 1 then
409
- set hit to item 1 of matches
410
- exit repeat
411
- end if
408
+ if (count of matches) is 1 then return item 1 of matches
412
409
  end if
413
410
  end repeat
411
+ return missing value
412
+ end findSidebarRow
413
+
414
+ on focusViaSidebar()
415
+ -- Press that row: no keystrokes at all, so nothing can be swallowed by a
416
+ -- focused field or fire a palette *command*. Being wrong is survivable here —
417
+ -- assertWorkspace re-checks before we type — and a miss falls back to the palette.
418
+ set hit to my findSidebarRow()
414
419
  if hit is missing value then return false
415
420
  tell application "System Events" to tell process "Conductor"
416
421
  perform action "AXPress" of hit
@@ -868,7 +873,164 @@ on applyAgentOptions()
868
873
  if wantEffort is not "" then my setEffort(wantEffort)
869
874
  if wantPlan is not "" then my setPlan(wantPlan)
870
875
  if wantFast is "1" then my pressFast()
871
- end applyAgentOptions`;
876
+ end applyAgentOptions
877
+
878
+ on axRole(el)
879
+ tell application "System Events" to tell process "Conductor"
880
+ try
881
+ return role of el
882
+ on error
883
+ return ""
884
+ end try
885
+ end tell
886
+ end axRole
887
+
888
+ on axKids(el)
889
+ tell application "System Events" to tell process "Conductor"
890
+ try
891
+ return UI elements of el
892
+ on error
893
+ return {}
894
+ end try
895
+ end tell
896
+ end axKids
897
+
898
+ on menusUnder(root, maxDepth)
899
+ -- Every AXMenu at or below root, breadth-first and bounded. The status
900
+ -- submenu opens *nested inside* the row menu rather than beside it, so a
901
+ -- search that stops at the first hit (the way setModel scans the web area's
902
+ -- direct children) never sees it.
903
+ set level to {root}
904
+ set found to {}
905
+ set depth to 0
906
+ repeat while (count of level) > 0 and depth < maxDepth
907
+ set nextLevel to {}
908
+ repeat with entry in level
909
+ set node to contents of entry
910
+ if (my axRole(node)) is "AXMenu" then set end of found to node
911
+ set nextLevel to nextLevel & (my axKids(node))
912
+ end repeat
913
+ set level to nextLevel
914
+ set depth to depth + 1
915
+ end repeat
916
+ return found
917
+ end menusUnder
918
+
919
+ on axName(el)
920
+ -- Guarded: a menu re-renders under us, and reading the name of an element that
921
+ -- has since gone throws an object-specifier error from wherever we happened to be.
922
+ tell application "System Events" to tell process "Conductor"
923
+ try
924
+ set n to name of el
925
+ if n is missing value then return ""
926
+ return n as text
927
+ on error
928
+ return ""
929
+ end try
930
+ end tell
931
+ end axName
932
+
933
+ on menuItemNamed(m, wanted)
934
+ repeat with mi in (my axKids(m))
935
+ set node to contents of mi
936
+ if (my axRole(node)) is "AXMenuItem" and (my axName(node)) is wanted then return node
937
+ end repeat
938
+ return missing value
939
+ end menuItemNamed
940
+
941
+ on waitForMenuWith(root, maxDepth, itemName, attempts)
942
+ -- Identify the menu by an item it *contains*, never as "the first AXMenu on
943
+ -- screen": a model picker left open, or a menu from the run before, is also an
944
+ -- AXMenu, and picking it produced "no Set status item" on a menu that had never
945
+ -- been the workspace's. A menu is drawn by the webview, not by us, so how long
946
+ -- it takes varies with what Conductor is busy doing — poll rather than guess
947
+ -- one delay; the common case costs a single sweep.
948
+ repeat with i from 1 to attempts
949
+ repeat with entry in my menusUnder(root, maxDepth)
950
+ set node to contents of entry
951
+ if (my menuItemNamed(node, itemName)) is not missing value then return node
952
+ end repeat
953
+ delay 0.5
954
+ end repeat
955
+ return missing value
956
+ end waitForMenuWith
957
+
958
+ on dismissMenus()
959
+ -- Two escapes: one for the submenu, one for the row menu. Leaving either open
960
+ -- would swallow the next run's keystrokes.
961
+ tell application "System Events"
962
+ key code 53
963
+ delay 0.25
964
+ key code 53
965
+ end tell
966
+ end dismissMenus
967
+
968
+ on setWorkspaceStatus()
969
+ -- Conductor has no menu-bar or palette command for this, so the only lever is
970
+ -- the sidebar row's own context menu (Mark as unread / Pin / Set status /
971
+ -- Rename / Copy link / Archive). Right-clicking the row needs no focus change,
972
+ -- so unlike a send this never disturbs which workspace is on screen.
973
+ set wanted to system attribute "RELAY_SET_STATUS"
974
+ if wanted is "" then error "no status requested"
975
+ set theRow to my findSidebarRow()
976
+ if theRow is missing value then
977
+ error "couldn't find this workspace in the sidebar — a collapsed section hides its row from Accessibility"
978
+ end if
979
+ -- Scroll it into view first. A row that exists in the AX tree but sits outside
980
+ -- the sidebar's visible strip accepts AXShowMenu and draws nothing — which is
981
+ -- exactly what happens right after a status change moves it to another group.
982
+ tell application "System Events" to tell process "Conductor"
983
+ try
984
+ perform action "AXScrollToVisible" of theRow
985
+ end try
986
+ end tell
987
+ delay 0.4
988
+ -- Clear anything already open (a picker, or a menu a previous run left behind)
989
+ -- so the sweep below can only match the one this right-click draws.
990
+ tell application "System Events" to key code 53
991
+ delay 0.3
992
+ tell application "System Events" to tell process "Conductor"
993
+ perform action "AXShowMenu" of theRow
994
+ end tell
995
+ -- Depth 4, not the whole tree: the row menu is a portal near the top of the web
996
+ -- area, and the *transcript* hangs off that same root — a deep sweep walks every
997
+ -- message bubble and costs more than the rest of this write put together (it
998
+ -- grows with the conversation, so it degrades as you use the app).
999
+ set rowMenu to my waitForMenuWith(my webArea(), 4, "Set status", 6)
1000
+ if rowMenu is missing value then
1001
+ my dismissMenus()
1002
+ error "the workspace's menu didn't open — or it no longer offers Set status"
1003
+ end if
1004
+ set statusItem to my menuItemNamed(rowMenu, "Set status")
1005
+ tell application "System Events" to tell process "Conductor"
1006
+ perform action "AXPress" of statusItem
1007
+ end tell
1008
+ -- The submenu is an AXMenu nested a few levels *inside* the row menu (Conductor
1009
+ -- expands it in place rather than opening a sibling), so search from that menu —
1010
+ -- searching the web area again would re-walk the world. Two things make this a
1011
+ -- poll rather than a read: the row-menu handle goes stale across the expansion
1012
+ -- (it reports no children instead of erroring), so it is re-found each pass; and
1013
+ -- the submenu's *items* arrive a beat after the submenu itself, so the thing we
1014
+ -- wait for is the wanted status, not the container that will hold it.
1015
+ set subMenu to missing value
1016
+ repeat with attempt from 1 to 8
1017
+ set liveMenu to my waitForMenuWith(my webArea(), 4, "Set status", 1)
1018
+ if liveMenu is not missing value then
1019
+ set subMenu to my waitForMenuWith(liveMenu, 5, wanted, 1)
1020
+ end if
1021
+ if subMenu is not missing value then exit repeat
1022
+ delay 0.4
1023
+ end repeat
1024
+ if subMenu is missing value then
1025
+ my dismissMenus()
1026
+ error "Conductor never offered a status called " & wanted
1027
+ end if
1028
+ set choice to my menuItemNamed(subMenu, wanted)
1029
+ tell application "System Events" to tell process "Conductor"
1030
+ perform action "AXPress" of choice
1031
+ end tell
1032
+ delay 0.6
1033
+ end setWorkspaceStatus`;
872
1034
  /** Conductor's command palette matches workspaces by branch — its unique key. A
873
1035
  * looser query (directory name) can match a command like unarchive, so prefer
874
1036
  * branch and only fall back when it's absent. */
@@ -1045,6 +1207,55 @@ return "ok"`.trim();
1045
1207
  return { ok: false, strategy: 'applescript', error: osaError(err) };
1046
1208
  }
1047
1209
  }
1210
+ /**
1211
+ * The workspace statuses Conductor's sidebar groups by, mapped from the value it
1212
+ * stores in `workspaces.manual_status` to the label on its own menu. `canceled`
1213
+ * is on the menu but has never been written in this DB, so it's the one spelling
1214
+ * here that is taken from the UI rather than confirmed against stored data — a
1215
+ * mismatch surfaces as a failed confirmation naming what Conductor actually wrote.
1216
+ */
1217
+ export const WORKSPACE_STATUS_LABELS = {
1218
+ backlog: 'Backlog',
1219
+ 'in-progress': 'In progress',
1220
+ 'in-review': 'In review',
1221
+ done: 'Done',
1222
+ canceled: 'Canceled'
1223
+ };
1224
+ /**
1225
+ * Move a workspace between the sidebar's status groups — the thing a merged PR
1226
+ * that Conductor never linked can't do for itself.
1227
+ *
1228
+ * Unlike every other write here this one never changes what's on screen: it
1229
+ * right-clicks the workspace's *row* (AXShowMenu) and works the menu, so the
1230
+ * workspace you were reading stays open. It does need the row to be rendered,
1231
+ * which a collapsed sidebar section prevents — that case is reported in words
1232
+ * rather than guessed around, because there is no palette command to fall back to.
1233
+ */
1234
+ export async function setWorkspaceStatus(workspace, status) {
1235
+ const label = WORKSPACE_STATUS_LABELS[status];
1236
+ if (!label)
1237
+ return { ok: false, strategy: 'applescript', error: `unknown status ${status}` };
1238
+ const script = `
1239
+ ${SELECT_CHAT_TAB_HANDLERS}
1240
+
1241
+ my activateConductor()
1242
+ my setWorkspaceStatus()
1243
+ return "ok"`.trim();
1244
+ try {
1245
+ await uiTurn(() => exec('osascript', ['-e', script], {
1246
+ env: {
1247
+ ...process.env,
1248
+ ...targetEnv({ workspace, sessionId: null }),
1249
+ RELAY_SET_STATUS: label
1250
+ },
1251
+ timeout: 25000
1252
+ }));
1253
+ return { ok: true, strategy: 'applescript' };
1254
+ }
1255
+ catch (err) {
1256
+ return { ok: false, strategy: 'applescript', error: osaError(err) };
1257
+ }
1258
+ }
1048
1259
  /** The model labels Conductor is currently offering, read off the live picker. */
1049
1260
  export async function listAgentModels(target) {
1050
1261
  const script = `
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.32.1",
3
+ "version": "1.34.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.",