conductor-remote 1.41.0 → 1.42.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.
@@ -1,31 +1,89 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
1
2
  import { execFile } from 'node:child_process';
2
3
  import { readFileSync } from 'node:fs';
3
4
  import path from 'node:path';
4
5
  import { promisify } from 'node:util';
5
6
  import { sidecarAvailable, sidecarSendUserMessage } from "./sidecar.js";
6
7
  const exec = promisify(execFile);
8
+ /** Waiting runs past this are refused rather than queued. */
9
+ const MAX_UI_QUEUE = 4;
10
+ const uiPriorityScope = new AsyncLocalStorage();
11
+ /** Run `fn` with every UI operation it triggers marked at `priority`. */
12
+ export function withUiPriority(priority, fn) {
13
+ return uiPriorityScope.run(priority, fn);
14
+ }
15
+ export class UiBusyError extends Error {
16
+ waiting;
17
+ constructor(waiting) {
18
+ super(`Conductor's UI is busy — ${waiting} operation${waiting === 1 ? '' : 's'} already queued. Try again shortly.`);
19
+ this.name = 'UiBusyError';
20
+ this.waiting = waiting;
21
+ }
22
+ }
23
+ let uiRunning = false;
24
+ let uiSeq = 0;
25
+ const uiWaiting = [];
26
+ /** What the UI lock is doing right now — `waiting` excludes the run in flight. */
27
+ export function uiQueueDepth() {
28
+ return { waiting: uiWaiting.length, busy: uiRunning };
29
+ }
30
+ function pumpUi() {
31
+ if (uiRunning)
32
+ return;
33
+ const next = uiWaiting.shift();
34
+ if (!next)
35
+ return;
36
+ uiRunning = true;
37
+ next.start();
38
+ }
7
39
  /**
8
- * One UI operation at a time.
9
- *
10
- * Every script below drives Conductor's *shared, single* window — focus a
11
- * workspace, select a tab, write the composer — so two of them overlapping
12
- * interleaves their steps and lands a prompt in whatever the other one focused.
13
- * That is the exact failure the whole fail-closed AX design exists to prevent,
14
- * and no amount of per-step assertion catches it, because each script's reads
15
- * are true at the moment it makes them.
16
- *
17
- * It was unreachable while every write was one person tapping one button. It
18
- * stopped being unreachable when the relay grew a first-prompt queue that sends
19
- * on its own schedule (`firstprompt.ts`), so the queue can now fire while the
20
- * phone is mid-send. Cheap insurance either way: these run for seconds, the
21
- * caller is already awaiting, and there is never a real queue of them.
40
+ * Take the lock, run `op`, release it. Exported for `scripts/check-uilock.ts`,
41
+ * which is the only way this queue's control flow gets read by anything.
22
42
  */
23
- let uiTail = Promise.resolve();
24
- function uiTurn(op) {
25
- // `.then(op, op)` so a previous failure doesn't skip the next turn.
26
- const turn = uiTail.then(op, op);
27
- uiTail = turn.catch(() => undefined);
28
- return turn;
43
+ export function uiTurn(op) {
44
+ const rank = uiPriorityScope.getStore() === 'background' ? 1 : 0;
45
+ if (uiWaiting.length >= MAX_UI_QUEUE)
46
+ return Promise.reject(new UiBusyError(uiWaiting.length));
47
+ return new Promise((resolve, reject) => {
48
+ const waiter = {
49
+ rank,
50
+ seq: uiSeq++,
51
+ start: () => {
52
+ // A throw *before* the first await is still this run's failure, not a crash
53
+ // that would leave the lock held forever.
54
+ let settled;
55
+ try {
56
+ settled = op();
57
+ }
58
+ catch (err) {
59
+ settled = Promise.reject(err);
60
+ }
61
+ // Release *before* resolving the caller, not after. Settling first and cleaning
62
+ // up in a chained `.then` frees the lock one microtask late, so code that awaits
63
+ // a write and then reads `uiQueueDepth()` is told a run is still in flight when
64
+ // none is — and the next turn starts a tick later than it could.
65
+ const release = () => {
66
+ uiRunning = false;
67
+ pumpUi();
68
+ };
69
+ settled.then(value => {
70
+ release();
71
+ resolve(value);
72
+ }, err => {
73
+ release();
74
+ reject(err);
75
+ });
76
+ }
77
+ };
78
+ // Stable insert: by rank, FIFO within a rank. A background run already started
79
+ // keeps the lock — this decides who is next, never who is interrupted.
80
+ const at = uiWaiting.findIndex(w => w.rank > rank);
81
+ if (at < 0)
82
+ uiWaiting.push(waiter);
83
+ else
84
+ uiWaiting.splice(at, 0, waiter);
85
+ pumpUi();
86
+ });
29
87
  }
30
88
  /**
31
89
  * How long one AppleScript run may take before it's killed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.41.0",
3
+ "version": "1.42.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.",
@@ -50,12 +50,13 @@
50
50
  "typecheck": "tsc -p tsconfig.json",
51
51
  "lint": "biome check .",
52
52
  "fix": "biome check . --fix",
53
- "verify": "yarn typecheck && yarn lint && yarn check:applescript && yarn check:nosleep",
53
+ "verify": "yarn typecheck && yarn lint && yarn check:applescript && yarn check:nosleep && yarn check:uilock",
54
54
  "release": "semantic-release",
55
55
  "prepack": "yarn build && yarn build:node",
56
56
  "postinstall": "husky || true",
57
57
  "check:applescript": "node scripts/check-applescript.ts",
58
- "check:nosleep": "node scripts/check-nosleep.ts"
58
+ "check:nosleep": "node scripts/check-nosleep.ts",
59
+ "check:uilock": "node scripts/check-uilock.ts"
59
60
  },
60
61
  "devDependencies": {
61
62
  "@biomejs/biome": "^2.3.8",