conductor-remote 1.32.0 → 1.33.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.
@@ -25,6 +25,55 @@ function uiTurn(op) {
25
25
  uiTail = turn.catch(() => undefined);
26
26
  return turn;
27
27
  }
28
+ /**
29
+ * How long one AppleScript run may take before it's killed.
30
+ *
31
+ * Sized from measurement, not taste. A send that *worked* measured 23.6s end to
32
+ * end on a 30-workspace sidebar — past the 20s ceiling this replaces, which is why
33
+ * ordinary sends were being killed mid-run and reported as "Conductor took too long
34
+ * to respond". The cost is Accessibility round trips, not waiting: activating a
35
+ * backgrounded Conductor and reading the pane cost ~10s cold, and finding the
36
+ * sidebar row to press another ~10s (see `atTargetWorkspace`, which is why a warm
37
+ * send is now ~2s). A ceiling costs nothing when a send is fast — only a doomed one
38
+ * waits it out, and the caller's own deadline is what bounds that.
39
+ */
40
+ export const SEND_ATTEMPT_MS = 28_000;
41
+ /**
42
+ * A run's own ceiling, taken off the caller's deadline at the moment it actually
43
+ * starts.
44
+ *
45
+ * Both halves matter. `uiTurn` above means a run can sit in the queue behind
46
+ * another write, so a duration computed when it was *requested* would let a queued
47
+ * run overshoot a deadline the caller is still holding a phone open on.
48
+ * `SEND_ATTEMPT_MS` then caps it, because a caller with a minute of budget still
49
+ * shouldn't spend all of it on one doomed run when a retry is the thing that works.
50
+ * The floor keeps a squeezed run honest instead of passing `timeout: 0`, which node
51
+ * reads as "no timeout at all".
52
+ */
53
+ function runCeiling(deadline) {
54
+ return Math.max(5_000, Math.min(SEND_ATTEMPT_MS, deadline - Date.now()));
55
+ }
56
+ /**
57
+ * Failures no amount of retrying will fix, so a caller that retries can stop at
58
+ * once instead of spending a whole budget to arrive at the same sentence.
59
+ *
60
+ * Matched on phrases this file writes itself — the first two from `refusalReason`,
61
+ * the rest from the target checks below — never on macOS's own wording, which we
62
+ * quote verbatim precisely because it drifts. The refusals are the ones a node
63
+ * upgrade causes by silently revoking Accessibility: they fail instantly and
64
+ * identically every time, so making the phone sit through a whole retry budget to
65
+ * be told a permission is missing is worse than being told at once. The rest are
66
+ * malformed targets — no session, no branch — which no attempt can turn into one.
67
+ */
68
+ const TERMINAL_ERRORS = [
69
+ 'not trusted for Accessibility',
70
+ 'blocked the relay from controlling the UI',
71
+ 'no session id to target',
72
+ 'workspace has no branch to focus'
73
+ ];
74
+ export function retryWontHelp(error) {
75
+ return error !== undefined && TERMINAL_ERRORS.some(phrase => error.includes(phrase));
76
+ }
28
77
  /**
29
78
  * The sidecar IPC path — the precise, per-session write. Delivers straight to
30
79
  * `sessionId` over Conductor's own dispatch socket (see sidecar.ts), so it needs
@@ -42,7 +91,8 @@ export class SidecarActuator {
42
91
  available() {
43
92
  return sidecarAvailable();
44
93
  }
45
- async send(target, text) {
94
+ /** `deadline` is ignored — the sidecar is one socket write, with no UI to wait on. */
95
+ async send(target, text, _deadline) {
46
96
  const sessionId = target.sessionId ?? target.workspace.active_session_id;
47
97
  if (!sessionId)
48
98
  return { ok: false, strategy: this.name, error: 'no session id to target' };
@@ -333,34 +383,39 @@ on sidebarLinks()
333
383
  end tell
334
384
  end sidebarLinks
335
385
 
336
- on focusViaSidebar()
337
- -- Press the workspace's sidebar row: no keystrokes at all, so nothing can be
338
- -- swallowed by a focused field or fire a palette *command*. Only rows that are
339
- -- actually rendered exist in the AX tree (a collapsed section has none), and
340
- -- the title precedence is Conductor's, so we try each candidate and require a
341
- -- unique hit — anything ambiguous falls back to the palette. Being wrong is
342
- -- 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.
343
391
  set titles to my splitLines(system attribute "RELAY_WS_TITLES")
344
- if (count of titles) is 0 then return false
392
+ if (count of titles) is 0 then return missing value
345
393
  set repoName to system attribute "RELAY_WS_REPO"
346
394
  set rows to my sidebarLinks()
347
- set hit to missing value
348
395
  repeat with candidate in titles
349
396
  if (candidate as text) is not "" then
350
397
  set matches to {}
351
398
  repeat with entry in rows
352
399
  set row to contents of entry
353
- 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)
354
404
  if rowName contains (candidate as text) then
355
405
  if repoName is "" or rowName contains repoName then set end of matches to row
356
406
  end if
357
407
  end repeat
358
- if (count of matches) is 1 then
359
- set hit to item 1 of matches
360
- exit repeat
361
- end if
408
+ if (count of matches) is 1 then return item 1 of matches
362
409
  end if
363
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()
364
419
  if hit is missing value then return false
365
420
  tell application "System Events" to tell process "Conductor"
366
421
  perform action "AXPress" of hit
@@ -382,16 +437,35 @@ on focusViaPalette()
382
437
  end tell
383
438
  end focusViaPalette
384
439
 
440
+ on paneAgrees()
441
+ -- Nothing in the open pane contradicts the target: a chat pane is there and its
442
+ -- header assertion passed (which is a no-op when there's no branch to check it
443
+ -- against). This is what confirms a sidebar press landed.
444
+ try
445
+ set strips to my tabGroups()
446
+ if (count of strips) is 0 then return false
447
+ my assertWorkspace(item 1 of strips)
448
+ return true
449
+ end try
450
+ return false
451
+ end paneAgrees
452
+
453
+ on atTargetWorkspace()
454
+ -- "Are we already there?" — the *strict* form of the question, so it needs a
455
+ -- branch to have been checked and not merely skipped. Worth asking first because
456
+ -- the pane header answers in ~0.5s where reading the sidebar's rows to press one
457
+ -- measured 5-7s, the single largest cost in a send: a phone sends to the same
458
+ -- workspace over and over, and Conductor stays where it was left. Safe as a
459
+ -- shortcut because it *is* the assertion the send makes before typing anyway.
460
+ if (system attribute "RELAY_WS_BRANCH") is "" then return false
461
+ return my paneAgrees()
462
+ end atTargetWorkspace
463
+
385
464
  on focusWorkspace()
386
465
  if (system attribute "RELAY_WS_QUERY") is "" then return
466
+ if my atTargetWorkspace() then return
387
467
  if my focusViaSidebar() then
388
- try
389
- set strips to my tabGroups()
390
- if (count of strips) > 0 then
391
- my assertWorkspace(item 1 of strips)
392
- return
393
- end if
394
- end try
468
+ if my paneAgrees() then return
395
469
  end if
396
470
  my focusViaPalette()
397
471
  end focusWorkspace
@@ -799,7 +873,164 @@ on applyAgentOptions()
799
873
  if wantEffort is not "" then my setEffort(wantEffort)
800
874
  if wantPlan is not "" then my setPlan(wantPlan)
801
875
  if wantFast is "1" then my pressFast()
802
- 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`;
803
1034
  /** Conductor's command palette matches workspaces by branch — its unique key. A
804
1035
  * looser query (directory name) can match a command like unarchive, so prefer
805
1036
  * branch and only fall back when it's absent. */
@@ -884,7 +1115,7 @@ export class AppleScriptActuator {
884
1115
  name = 'applescript';
885
1116
  caveat = 'Focuses the target workspace (Cmd+K) and its chat tab before sending.';
886
1117
  precise = true;
887
- async send(target, text) {
1118
+ async send(target, text, deadline = Date.now() + SEND_ATTEMPT_MS) {
888
1119
  // Focus the target workspace, select its chat tab, fill the composer, send.
889
1120
  // Filling is an Accessibility write (no keystrokes, no clipboard); the
890
1121
  // clipboard paste is kept only as a fallback, and stashes/restores around it.
@@ -914,7 +1145,7 @@ end tell
914
1145
  try {
915
1146
  await uiTurn(() => exec('osascript', ['-e', script], {
916
1147
  env: { ...process.env, RELAY_PROMPT_FILE: tmp, ...targetEnv(target) },
917
- timeout: 20000
1148
+ timeout: runCeiling(deadline)
918
1149
  }));
919
1150
  return { ok: true, strategy: this.name };
920
1151
  }
@@ -968,6 +1199,55 @@ return "ok"`.trim();
968
1199
  RELAY_SET_FAST: opts.toggleFast ? '1' : '',
969
1200
  RELAY_SET_MODEL: opts.model ?? ''
970
1201
  },
1202
+ timeout: SEND_ATTEMPT_MS
1203
+ }));
1204
+ return { ok: true, strategy: 'applescript' };
1205
+ }
1206
+ catch (err) {
1207
+ return { ok: false, strategy: 'applescript', error: osaError(err) };
1208
+ }
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
+ },
971
1251
  timeout: 25000
972
1252
  }));
973
1253
  return { ok: true, strategy: 'applescript' };
@@ -988,7 +1268,7 @@ return my listModels()`.trim();
988
1268
  try {
989
1269
  const { stdout } = await uiTurn(() => exec('osascript', ['-e', script], {
990
1270
  env: { ...process.env, ...targetEnv(target) },
991
- timeout: 25000
1271
+ timeout: SEND_ATTEMPT_MS
992
1272
  }));
993
1273
  const models = stdout
994
1274
  .split('\n')
@@ -1060,9 +1340,11 @@ tell application "System Events"
1060
1340
  keystroke "t" using {command down}
1061
1341
  end tell`.trim();
1062
1342
  try {
1343
+ // Shares the focus path with a send, so it needs the same ceiling: 15s was under
1344
+ // the cost of activating a cold Conductor and finding the row on its own.
1063
1345
  await uiTurn(() => exec('osascript', ['-e', script], {
1064
1346
  env: { ...process.env, ...targetEnv({ workspace, sessionId: null }) },
1065
- timeout: 15000
1347
+ timeout: SEND_ATTEMPT_MS
1066
1348
  }));
1067
1349
  return { ok: true, strategy: 'applescript' };
1068
1350
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.32.0",
3
+ "version": "1.33.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.",