conductor-remote 1.41.0 → 1.42.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.
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The handful of things the relay and the phone must compute *identically*, in the
3
+ * one place both can import.
4
+ *
5
+ * `src/` and `web/src/` are one TypeScript project (tsconfig.json includes both), so
6
+ * a plain relative import crosses between them fine. What does not cross is Node: the
7
+ * moment this file imports `node:anything` it stops being bundleable and every web
8
+ * import of it becomes a build failure. **So this module stays stdlib-free — no
9
+ * `node:` imports, ever.** It is the only file under `src/` the web app may import a
10
+ * *value* from; everything else it may only `import type` (see src/wire.ts), which
11
+ * `verbatimModuleSyntax` erases before the bundler ever sees it.
12
+ *
13
+ * Each of these was a second copy before, and each copy was a way for two screens to
14
+ * disagree about the same workspace.
15
+ */
16
+ /**
17
+ * The branch minus its prefix, sentence-cased — Conductor's own fallback title while a
18
+ * workspace is still in progress. Prefix-agnostic (github_username / custom / none): it
19
+ * strips the first path segment rather than reading Conductor's `branch_prefix_type`
20
+ * setting, because the branch already embeds whichever prefix was resolved.
21
+ */
22
+ export function humanizeBranch(branch) {
23
+ const b = branch ?? '';
24
+ const slug = b.includes('/') ? b.slice(b.indexOf('/') + 1) : b;
25
+ const words = slug.replace(/[-_]/g, ' ').trim();
26
+ return words ? words[0].toUpperCase() + words.slice(1) : '';
27
+ }
28
+ /**
29
+ * Conductor's own sidebar title for a workspace: manual name, then PR title, then the
30
+ * humanized branch, then the worktree codename, then the id.
31
+ *
32
+ * `pr_title` is Conductor's cached PR title, present exactly when the workspace has a PR
33
+ * (in-review or done) and cleared back to empty otherwise, so it is the live sidebar
34
+ * title rather than a stale value. `directory_name` (the worktree codename, e.g.
35
+ * "managua-v2") is the last resort for a branchless workspace.
36
+ *
37
+ * Three callers have to agree — the sidebar list on the phone, the workspace a push
38
+ * notification names (src/notify.ts), and the workspace an MCP tool result names
39
+ * (src/mcp-tools.ts). A notification that titles a workspace differently from the list
40
+ * it came from reads as a different workspace.
41
+ */
42
+ export function workspaceTitle(w) {
43
+ return w.workspace_name || w.pr_title || humanizeBranch(w.branch) || w.directory_name || w.id.slice(0, 8);
44
+ }
45
+ /**
46
+ * The words a query actually searches for. The phone filters the live workspace list
47
+ * with these while the relay searches the transcript index with the same call, so two
48
+ * different splits would make one list disagree with the other on the same keystroke.
49
+ */
50
+ export function queryTokens(raw) {
51
+ return raw.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? [];
52
+ }
53
+ /**
54
+ * Markers the relay wraps search hits in (src/search.ts, via FTS5 `snippet()`). They
55
+ * are control characters, so they must never reach the DOM: an unsplit snippet renders
56
+ * as invisible garbage between the words it was meant to emphasise.
57
+ */
58
+ export const HIT_OPEN = '\u0001';
59
+ export const HIT_CLOSE = '\u0002';
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The HTTP contract: every shape that crosses `/api`, in one place, named once.
3
+ *
4
+ * This file holds **no runtime code**. It re-exports the relay's own domain types
5
+ * under the names the wire uses, and declares the response envelopes the route
6
+ * handlers assemble. Three callers read it and none of them may disagree:
7
+ *
8
+ * - `src/server.ts` builds these payloads,
9
+ * - `web/src/lib/types.ts` re-exports the lot for the PWA,
10
+ * - `src/mcp-tools.ts` annotates its relay calls with them.
11
+ *
12
+ * Before this, the phone kept a hand-written mirror of all of it and `mcp-tools.ts`
13
+ * kept a third copy inline. Nothing enforced the copies — a field renamed in
14
+ * `reads.ts` typechecked cleanly on both sides and surfaced as `undefined` on a
15
+ * phone. So the rule is: **a shape that leaves the relay is declared here, and
16
+ * nowhere else.**
17
+ *
18
+ * The web app may only `import type` from `src/` (see `scripts/check-imports.ts`).
19
+ * `verbatimModuleSyntax` erases those imports, so nothing here reaches the bundle —
20
+ * which is what lets a type live beside the `node:sqlite` code that produces it.
21
+ * The one exception is `src/shared.ts`, which is stdlib-free on purpose.
22
+ */
23
+ export {};
@@ -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.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.",
@@ -50,12 +50,14 @@
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:imports && 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:imports": "node scripts/check-imports.ts",
59
+ "check:nosleep": "node scripts/check-nosleep.ts",
60
+ "check:uilock": "node scripts/check-uilock.ts"
59
61
  },
60
62
  "devDependencies": {
61
63
  "@biomejs/biome": "^2.3.8",