conductor-remote 1.30.2 → 1.30.3

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-Bzpilddl.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-B2SjHT-B.js"></script>
17
17
  <link rel="stylesheet" crossorigin href="/assets/index-8aCnDSQJ.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,i={};const n=(n,s)=>(n=new URL(n+".js",s).href,i[n]||new Promise(i=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=i,document.head.appendChild(e)}else e=n,importScripts(n),i()}).then(()=>{let e=i[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e}));self.define=(s,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(i[o])return;let l={};const t=e=>n(e,o),c={module:{uri:o},exports:l,require:t};i[o]=Promise.all(s.map(e=>c[e]||t(e))).then(e=>(r(...e),l))}}define(["./workbox-9c191d2f"],function(e){"use strict";self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"index.html",revision:"a34e4b0b1556a8c24be6825befa11735"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-Bzpilddl.js",revision:null},{url:"assets/index-8aCnDSQJ.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\//]}))});
1
+ if(!self.define){let e,i={};const n=(n,s)=>(n=new URL(n+".js",s).href,i[n]||new Promise(i=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=i,document.head.appendChild(e)}else e=n,importScripts(n),i()}).then(()=>{let e=i[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e}));self.define=(s,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(i[o])return;let l={};const c=e=>n(e,o),t={module:{uri:o},exports:l,require:c};i[o]=Promise.all(s.map(e=>t[e]||c(e))).then(e=>(r(...e),l))}}define(["./workbox-9c191d2f"],function(e){"use strict";self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"index.html",revision:"32efee3d01d4dec7873e83faf76854ca"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-B2SjHT-B.js",revision:null},{url:"assets/index-8aCnDSQJ.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\//]}))});
@@ -4,9 +4,16 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { packageRoot } from "./pkg-root.js";
6
6
  const home = os.homedir();
7
+ /**
8
+ * The relay's own state directory — its token, its Funnel posture, its undelivered
9
+ * first prompts. Never Conductor's: everything about Conductor is read from the DB.
10
+ */
11
+ export function stateDir() {
12
+ return path.join(home, 'Library', 'Application Support', 'conductor-remote');
13
+ }
7
14
  /** Where a generated token is persisted so a phone's saved URL stays valid across relay restarts. */
8
15
  function tokenStorePath() {
9
- return path.join(home, 'Library', 'Application Support', 'conductor-remote', 'token');
16
+ return path.join(stateDir(), 'token');
10
17
  }
11
18
  /**
12
19
  * Stable shared secret. Explicit `RELAY_TOKEN` wins; otherwise reuse a persisted token (or mint and
@@ -0,0 +1,198 @@
1
+ /**
2
+ * The first prompt of a workspace created from the phone, delivered by the relay
3
+ * once Conductor has finished setting the worktree up.
4
+ *
5
+ * Conductor's deep link creates the workspace and *pre-fills* its composer, but
6
+ * never presses Enter — so something has to, ~30s later, when the worktree turns
7
+ * `ready` and the chat exists. That "something" used to be the PWA, which is the
8
+ * worst possible scheduler for it: the phone sleeps, iOS suspends a backgrounded
9
+ * PWA outright, and it may not be on the network at all. Meanwhile the relay is a
10
+ * daemon on the same Mac as the target, already holding the DB and the actuator.
11
+ * So the relay owns delivery and the phone only *watches* it (`/api/state`
12
+ * carries the pending prompt, `DELETE …/prompt` dismisses one).
13
+ *
14
+ * Three properties this has to keep:
15
+ *
16
+ * - **One owner.** If the phone delivered too, `last_user_message_at` wouldn't
17
+ * save us — it's a read, not a lock, and both sides can read it null. The PWA
18
+ * no longer parks anything.
19
+ * - **It survives a restart.** `autoupdate` deliberately `exit()`s to reload new
20
+ * code, and launchd brings us straight back; a queue that only lived in memory
21
+ * would drop the prompt mid-setup, which is the same swallowed prompt in a new
22
+ * house. Hence the JSON file, rewritten on every change.
23
+ * - **It gives up in public.** After `MAX_ATTEMPTS` sends or `MAX_AGE_MS` of a
24
+ * workspace that never turns ready, the entry flips to `failed` *and stays*, so
25
+ * the phone can show the text with the reason next to it. Nothing is silently
26
+ * dropped — and the text is still sitting pre-filled in Conductor's composer on
27
+ * the Mac regardless.
28
+ */
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ /** How often the loop re-reads the DB while waiting for a worktree. */
32
+ const POLL_MS = 1000;
33
+ /** Breathing room between failed sends — Conductor may be mid-launch or showing a dialog. */
34
+ const RETRY_DELAY_MS = 5000;
35
+ const MAX_ATTEMPTS = 3;
36
+ /** A workspace that hasn't turned ready in this long isn't going to. */
37
+ const MAX_AGE_MS = 15 * 60 * 1000;
38
+ /** Failed entries the user never dismissed are still dropped eventually. */
39
+ const KEEP_FAILED_MS = 7 * 24 * 60 * 60 * 1000;
40
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
41
+ export class FirstPromptQueue {
42
+ // Explicit field assignment, not parameter properties: the dev run type-*strips*
43
+ // rather than transforms, and parameter properties need a transform (see CLAUDE.md).
44
+ file;
45
+ deps;
46
+ entries;
47
+ pumping = false;
48
+ /** Resolvers waiting on a specific entry to settle (`POST /api/workspaces` with `send:true`). */
49
+ waiters = new Map();
50
+ constructor(file, deps) {
51
+ this.file = file;
52
+ this.deps = deps;
53
+ this.entries = this.load();
54
+ }
55
+ /** Everything the phone should see, including entries that have already failed. */
56
+ list() {
57
+ return this.entries;
58
+ }
59
+ get(workspaceId) {
60
+ return this.entries.find(e => e.workspaceId === workspaceId) ?? null;
61
+ }
62
+ /**
63
+ * Park a prompt and start delivering it. The returned promise settles when the
64
+ * prompt lands (`null`) or is given up on (the failed entry) — awaited by API
65
+ * callers that asked to block, ignored by the phone.
66
+ */
67
+ enqueue(workspaceId, text) {
68
+ this.entries = [
69
+ ...this.entries.filter(e => e.workspaceId !== workspaceId),
70
+ { workspaceId, text, status: 'waiting', attempts: 0, createdAt: Date.now() }
71
+ ];
72
+ this.save();
73
+ const settled = new Promise(resolve => {
74
+ const list = this.waiters.get(workspaceId) ?? [];
75
+ list.push(resolve);
76
+ this.waiters.set(workspaceId, list);
77
+ });
78
+ void this.pump();
79
+ return settled;
80
+ }
81
+ /** Drop an entry — dismissed from the phone, or superseded by a send the user made themselves. */
82
+ forget(workspaceId) {
83
+ if (!this.entries.some(e => e.workspaceId === workspaceId))
84
+ return false;
85
+ this.entries = this.entries.filter(e => e.workspaceId !== workspaceId);
86
+ this.save();
87
+ this.settle(workspaceId, null);
88
+ return true;
89
+ }
90
+ /** Resume delivery of anything left over from a previous process. */
91
+ start() {
92
+ const waiting = this.entries.filter(e => e.status === 'waiting').length;
93
+ if (waiting)
94
+ console.info(`[relay] resuming ${waiting} undelivered first prompt(s)`);
95
+ void this.pump();
96
+ }
97
+ /**
98
+ * The delivery loop. One pass per second over everything still waiting; exits
99
+ * when nothing is (and `enqueue`/`start` re-enter it). Single-flight, so the
100
+ * loop can't be stacked by a burst of creations.
101
+ */
102
+ async pump() {
103
+ if (this.pumping)
104
+ return;
105
+ this.pumping = true;
106
+ try {
107
+ while (this.entries.some(e => e.status === 'waiting')) {
108
+ for (const entry of this.entries.filter(e => e.status === 'waiting'))
109
+ await this.step(entry);
110
+ if (this.entries.some(e => e.status === 'waiting'))
111
+ await sleep(POLL_MS);
112
+ }
113
+ }
114
+ catch (err) {
115
+ // A throw here would strand every waiting prompt with no loop to retry it.
116
+ console.error('[relay] first-prompt delivery loop crashed:', err);
117
+ }
118
+ finally {
119
+ this.pumping = false;
120
+ }
121
+ }
122
+ async step(entry) {
123
+ if (Date.now() - entry.createdAt > MAX_AGE_MS) {
124
+ return this.fail(entry, 'the workspace never finished setting up');
125
+ }
126
+ const target = this.deps.inspect(entry.workspaceId);
127
+ // No row yet is normal right after creation, and a workspace that really is
128
+ // gone falls out through the age cap above rather than being guessed at here.
129
+ if (!target?.ready || !target.sessionId)
130
+ return;
131
+ // It already went — the user sent it from the Mac, where the deep link left it
132
+ // pre-filled in the composer. Sending again would double it.
133
+ if (target.alreadySent)
134
+ return this.delivered(entry);
135
+ entry.attempts += 1;
136
+ this.save();
137
+ const result = await this.deps.send(entry.workspaceId, target.sessionId, entry.text);
138
+ if (result.ok)
139
+ return this.delivered(entry);
140
+ const error = result.error ?? 'the send didn’t land';
141
+ console.warn(`[relay] first prompt for ${entry.workspaceId} failed (attempt ${entry.attempts}): ${error}`);
142
+ if (entry.attempts >= MAX_ATTEMPTS)
143
+ return this.fail(entry, error);
144
+ await sleep(RETRY_DELAY_MS);
145
+ }
146
+ /** Delivered: the entry's job is done, so it stops existing. */
147
+ delivered(entry) {
148
+ this.entries = this.entries.filter(e => e !== entry);
149
+ this.save();
150
+ this.settle(entry.workspaceId, null);
151
+ }
152
+ /** Given up on: kept, so the phone can show the text and the reason. */
153
+ fail(entry, error) {
154
+ entry.status = 'failed';
155
+ entry.error = error;
156
+ this.save();
157
+ this.settle(entry.workspaceId, entry);
158
+ }
159
+ settle(workspaceId, result) {
160
+ const list = this.waiters.get(workspaceId);
161
+ if (!list)
162
+ return;
163
+ this.waiters.delete(workspaceId);
164
+ for (const resolve of list)
165
+ resolve(result);
166
+ }
167
+ load() {
168
+ let raw;
169
+ try {
170
+ raw = fs.readFileSync(this.file, 'utf8');
171
+ }
172
+ catch {
173
+ return []; // nothing parked yet — the common case
174
+ }
175
+ try {
176
+ const parsed = JSON.parse(raw);
177
+ if (!Array.isArray(parsed))
178
+ return [];
179
+ return parsed.filter(e => typeof e?.workspaceId === 'string' &&
180
+ typeof e.text === 'string' &&
181
+ Date.now() - (e.createdAt ?? 0) < KEEP_FAILED_MS);
182
+ }
183
+ catch (err) {
184
+ console.warn(`[relay] ignoring unreadable ${this.file}: ${err instanceof Error ? err.message : err}`);
185
+ return [];
186
+ }
187
+ }
188
+ save() {
189
+ try {
190
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
191
+ fs.writeFileSync(this.file, JSON.stringify(this.entries, null, 2));
192
+ }
193
+ catch (err) {
194
+ // Delivery still works this run; only the restart guarantee is lost.
195
+ console.warn(`[relay] could not persist pending prompts: ${err instanceof Error ? err.message : err}`);
196
+ }
197
+ }
198
+ }
@@ -4,8 +4,9 @@ import http from 'node:http';
4
4
  import path from 'node:path';
5
5
  import zlib from 'node:zlib';
6
6
  import { startAutoUpdate, updateStatus } from "./autoupdate.js";
7
- import { loadConfig } from "./config.js";
7
+ import { loadConfig, stateDir } from "./config.js";
8
8
  import { ConductorDb } from "./db.js";
9
+ import { FirstPromptQueue } from "./firstprompt.js";
9
10
  import { startFunnelWatchdog } from "./funnel-watchdog.js";
10
11
  import { workspaceDiff } from "./git.js";
11
12
  import { installLogCapture, isManaged, LOG_FILE_NAMES, logFiles, processStartedAt, recentLogs, redactSecrets, tailLogFile } from "./logbuf.js";
@@ -77,35 +78,60 @@ async function confirmAgentOptions(ws, sessionId, opts) {
77
78
  return false;
78
79
  }
79
80
  /**
80
- * Send a freshly-created workspace's first prompt. Never fails the request: the
81
- * workspace exists either way, so an unsent prompt is a `warning` (it's still
82
- * pre-filled in Conductor's composer, one tap from going) rather than an error.
81
+ * Deliver a prompt to one chat and confirm it landed. The single write path: the
82
+ * phone's own sends go through it, and so does the first-prompt queue, so both
83
+ * get the same targeting, the same read-back and the same errors.
83
84
  */
84
- async function submitFirstPrompt(workspaceId, prompt) {
85
- // A new workspace is 'setting_up' while its worktree (and setup script) runs;
86
- // its composer isn't the visible pane yet, so wait for 'ready' before typing.
87
- let ws = reads.getWorkspace(workspaceId);
88
- for (let attempt = 0; attempt < 60 && ws?.state !== 'ready'; attempt++) {
89
- await sleep(500);
90
- ws = reads.getWorkspace(workspaceId);
91
- }
92
- if (ws?.state !== 'ready') {
93
- return { sent: false, warning: 'Workspace created; still setting up, so the prompt is pre-filled but not sent.' };
94
- }
95
- const located = locateChat(ws, reads.listSessions(workspaceId)[0]?.id ?? '');
96
- const tab = 'error' in located ? undefined : located.tab;
97
- const sessionId = reads.listSessions(workspaceId)[0]?.id;
98
- if (!sessionId)
99
- return { sent: false, warning: 'Workspace created, but it has no chat yet — prompt is pre-filled.' };
85
+ async function deliverPrompt(ws, sessionId, text) {
86
+ const located = locateChat(ws, sessionId);
87
+ if ('error' in located)
88
+ return { ok: false, strategy: actuator.name, error: located.error };
89
+ // Snapshot the transcript cursor so we can confirm the prompt actually lands.
100
90
  const beforeRowid = reads.getMessages(sessionId).cursor;
101
- const result = await actuator.send({ workspace: ws, sessionId, tab }, prompt);
102
- if (!result.ok)
103
- return { sent: false, warning: `Workspace created; the prompt is pre-filled but wasn’t sent (${result.error}).` };
104
- if (!(await confirmDelivery(sessionId, prompt, beforeRowid))) {
105
- return { sent: false, warning: 'Workspace created; the prompt is pre-filled but didn’t land — send it again.' };
91
+ const result = await actuator.send({ workspace: ws, sessionId, tab: located.tab }, text);
92
+ if (!result.ok) {
93
+ console.warn(`[relay] send to ${ws.branch ?? ws.id} failed: ${result.error}`);
94
+ return result;
95
+ }
96
+ if (!(await confirmDelivery(sessionId, text, beforeRowid))) {
97
+ // The phone only ever sees "try again"; the reason a send goes missing lives
98
+ // on this side, so leave a trail in relay.log rather than nothing at all.
99
+ console.warn(`[relay] send to ${ws.branch ?? ws.id} drove the UI but never landed in the chat`);
100
+ return {
101
+ ok: false,
102
+ strategy: result.strategy,
103
+ error: 'Send didn’t land in the chat — Conductor may have been asleep or unfocused. Try again.'
104
+ };
106
105
  }
107
- return { sent: true };
106
+ return result;
108
107
  }
108
+ /**
109
+ * Undelivered first prompts, owned by this process rather than by the phone (see
110
+ * firstprompt.ts for why). Everything Conductor-side it needs is a plain DB read.
111
+ */
112
+ const firstPrompts = new FirstPromptQueue(path.join(stateDir(), 'first-prompts.json'), {
113
+ inspect: workspaceId => {
114
+ const ws = reads.getWorkspace(workspaceId);
115
+ if (!ws)
116
+ return null;
117
+ // A new workspace is 'setting_up' while its worktree (and setup script) runs;
118
+ // its composer isn't the visible pane yet, so wait for 'ready' before typing.
119
+ const sessions = reads.listSessions(workspaceId);
120
+ const session = sessions.find(s => s.id === ws.active_session_id) ?? sessions[0];
121
+ return {
122
+ ready: ws.state === 'ready',
123
+ sessionId: session?.id ?? null,
124
+ alreadySent: !!session?.last_user_message_at
125
+ };
126
+ },
127
+ send: async (workspaceId, sessionId, text) => {
128
+ const ws = reads.getWorkspace(workspaceId);
129
+ if (!ws)
130
+ return { ok: false, error: 'the workspace is gone' };
131
+ const result = await deliverPrompt(ws, sessionId, text);
132
+ return { ok: result.ok, error: result.error };
133
+ }
134
+ });
109
135
  const MIME = {
110
136
  '.html': 'text/html; charset=utf-8',
111
137
  '.js': 'text/javascript; charset=utf-8',
@@ -212,6 +238,10 @@ const server = http.createServer(async (req, res) => {
212
238
  const update = updateStatus();
213
239
  const workspaces = reads.listWorkspaces();
214
240
  attachPrStatus(workspaces); // colours pr_status from cache; refreshes stale entries in the background
241
+ // An undelivered first prompt rides along with its workspace: the phone renders it
242
+ // in that chat rather than tracking delivery itself (see src/firstprompt.ts).
243
+ for (const ws of workspaces)
244
+ ws.pending_prompt = firstPrompts.get(ws.id);
215
245
  return json(req, res, 200, {
216
246
  workspaces,
217
247
  actuator: await describeActuator(actuator),
@@ -285,18 +315,21 @@ const server = http.createServer(async (req, res) => {
285
315
  error: 'Conductor didn’t create a workspace — check it’s running and not showing a dialog.'
286
316
  });
287
317
  }
288
- // Return as soon as the row exists (~2s) and let the caller submit the prompt
289
- // once the worktree is ready. Waiting here would block the request through
290
- // Conductor's whole setup measured at 30s+ on a real repo, past the phone's
291
- // own 25s budget. `send:true` opts into the blocking path for API callers.
318
+ // Return as soon as the row exists (~2s) waiting for delivery would block the
319
+ // request through Conductor's whole setup, measured at 30s+ on a real repo and
320
+ // past the phone's own 25s budget. The queue delivers on its own schedule and
321
+ // the phone watches it in /api/state; `send:true` opts API callers into waiting.
292
322
  // Whatever happens, the prompt is already pre-filled in Conductor's composer.
293
- const submitted = body.send === true && prompt ? await submitFirstPrompt(created.id, prompt) : { sent: false };
323
+ const settled = prompt ? firstPrompts.enqueue(created.id, prompt) : null;
324
+ const failed = settled && body.send === true ? await settled : null;
325
+ settled?.catch(() => undefined); // fire-and-forget: it reports failure, it never rejects
294
326
  return json(req, res, 200, {
295
327
  ok: true,
296
328
  workspaceId: created.id,
297
329
  workspace: reads.getWorkspace(created.id) ?? created,
298
330
  pendingPrompt: prompt || undefined,
299
- ...submitted
331
+ sent: body.send === true ? !failed : false,
332
+ warning: failed?.error && `Workspace created; the prompt is pre-filled but wasn’t sent (${failed.error}).`
300
333
  });
301
334
  }
302
335
  // GET /api/repos/:name/icon — the repo's resolved sidebar icon (see src/icons.ts)
@@ -425,27 +458,21 @@ const server = http.createServer(async (req, res) => {
425
458
  : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
426
459
  if (!ws)
427
460
  return json(req, res, 404, { error: 'workspace for session not found' });
428
- const located = locateChat(ws, sessionId);
429
- if ('error' in located)
430
- return json(req, res, 409, { error: located.error });
431
- const { tab } = located;
432
- // Snapshot the transcript cursor so we can confirm the prompt actually lands.
433
- const beforeRowid = reads.getMessages(sessionId).cursor;
434
- const result = await actuator.send({ workspace: ws, sessionId, tab }, text);
435
- if (result.ok && !(await confirmDelivery(sessionId, text, beforeRowid))) {
436
- // The phone only ever sees "try again"; the reason a send goes missing lives
437
- // on this side, so leave a trail in relay.log rather than nothing at all.
438
- console.warn(`[relay] send to ${ws.branch ?? ws.id} drove the UI but never landed in the chat`);
439
- return json(req, res, 502, {
440
- ok: false,
441
- strategy: result.strategy,
442
- error: 'Send didn’t land in the chat — Conductor may have been asleep or unfocused. Try again.'
443
- });
444
- }
445
- if (!result.ok)
446
- console.warn(`[relay] send to ${ws.branch ?? ws.id} failed: ${result.error}`);
461
+ const result = await deliverPrompt(ws, sessionId, text);
462
+ // Whatever the queue was still holding for this workspace has now been said by
463
+ // hand including a failed entry the user retried from the chat.
464
+ if (result.ok)
465
+ firstPrompts.forget(ws.id);
447
466
  return json(req, res, result.ok ? 200 : 502, result);
448
467
  }
468
+ // DELETE /api/workspaces/:id/prompt — dismiss an undelivered first prompt
469
+ m = pathname.match(/^\/api\/workspaces\/([^/]+)\/prompt$/);
470
+ if (req.method === 'DELETE' && m) {
471
+ const workspaceId = decodeURIComponent(m[1]);
472
+ if (!firstPrompts.forget(workspaceId))
473
+ return json(req, res, 404, { error: 'no pending prompt' });
474
+ return json(req, res, 200, { ok: true });
475
+ }
449
476
  return json(req, res, 404, { error: 'no route', pathname });
450
477
  }
451
478
  catch (err) {
@@ -473,6 +500,9 @@ server.listen(cfg.port, cfg.host, () => {
473
500
  if (drift.length)
474
501
  console.info(`\n${drift.join('\n')}`);
475
502
  }
503
+ // Pick up any first prompt the previous process was still holding — an auto-update
504
+ // restart lands mid-setup often enough that this is the normal path, not a rare one.
505
+ firstPrompts.start();
476
506
  // Keep the managed global daemon current — no-ops for dev checkouts / unmanaged runs (see autoupdate.ts).
477
507
  startAutoUpdate();
478
508
  // Keep the phone's public URL reachable — re-registers Funnel when its ingress goes stale after a
@@ -2,6 +2,29 @@ import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { sidecarAvailable, sidecarSendUserMessage } from "./sidecar.js";
4
4
  const exec = promisify(execFile);
5
+ /**
6
+ * One UI operation at a time.
7
+ *
8
+ * Every script below drives Conductor's *shared, single* window — focus a
9
+ * workspace, select a tab, write the composer — so two of them overlapping
10
+ * interleaves their steps and lands a prompt in whatever the other one focused.
11
+ * That is the exact failure the whole fail-closed AX design exists to prevent,
12
+ * and no amount of per-step assertion catches it, because each script's reads
13
+ * are true at the moment it makes them.
14
+ *
15
+ * It was unreachable while every write was one person tapping one button. It
16
+ * stopped being unreachable when the relay grew a first-prompt queue that sends
17
+ * on its own schedule (`firstprompt.ts`), so the queue can now fire while the
18
+ * phone is mid-send. Cheap insurance either way: these run for seconds, the
19
+ * caller is already awaiting, and there is never a real queue of them.
20
+ */
21
+ let uiTail = Promise.resolve();
22
+ function uiTurn(op) {
23
+ // `.then(op, op)` so a previous failure doesn't skip the next turn.
24
+ const turn = uiTail.then(op, op);
25
+ uiTail = turn.catch(() => undefined);
26
+ return turn;
27
+ }
5
28
  /**
6
29
  * The sidecar IPC path — the precise, per-session write. Delivers straight to
7
30
  * `sessionId` over Conductor's own dispatch socket (see sidecar.ts), so it needs
@@ -889,10 +912,10 @@ end tell
889
912
  const tmp = path.join(os.tmpdir(), `relay-prompt-${process.pid}-${Date.now()}.txt`);
890
913
  await fs.writeFile(tmp, text, 'utf8');
891
914
  try {
892
- await exec('osascript', ['-e', script], {
915
+ await uiTurn(() => exec('osascript', ['-e', script], {
893
916
  env: { ...process.env, RELAY_PROMPT_FILE: tmp, ...targetEnv(target) },
894
917
  timeout: 20000
895
- });
918
+ }));
896
919
  return { ok: true, strategy: this.name };
897
920
  }
898
921
  catch (err) {
@@ -936,7 +959,7 @@ my selectChatTab()
936
959
  my applyAgentOptions()
937
960
  return "ok"`.trim();
938
961
  try {
939
- await exec('osascript', ['-e', script], {
962
+ await uiTurn(() => exec('osascript', ['-e', script], {
940
963
  env: {
941
964
  ...process.env,
942
965
  ...targetEnv(target),
@@ -946,7 +969,7 @@ return "ok"`.trim();
946
969
  RELAY_SET_MODEL: opts.model ?? ''
947
970
  },
948
971
  timeout: 25000
949
- });
972
+ }));
950
973
  return { ok: true, strategy: 'applescript' };
951
974
  }
952
975
  catch (err) {
@@ -963,10 +986,10 @@ my focusWorkspace()
963
986
  my selectChatTab()
964
987
  return my listModels()`.trim();
965
988
  try {
966
- const { stdout } = await exec('osascript', ['-e', script], {
989
+ const { stdout } = await uiTurn(() => exec('osascript', ['-e', script], {
967
990
  env: { ...process.env, ...targetEnv(target) },
968
991
  timeout: 25000
969
- });
992
+ }));
970
993
  const models = stdout
971
994
  .split('\n')
972
995
  .map(s => s.trim())
@@ -1011,7 +1034,9 @@ export async function createWorkspace(prompt, repoPath) {
1011
1034
  .filter(Boolean)
1012
1035
  .join('&');
1013
1036
  try {
1014
- await exec('open', [`conductor://${query}`], { timeout: 15000 });
1037
+ // Serialized with the AX writes: creating a workspace pulls Conductor forward and
1038
+ // switches which one is showing, which is precisely what a concurrent send assumes.
1039
+ await uiTurn(() => exec('open', [`conductor://${query}`], { timeout: 15000 }));
1015
1040
  return { ok: true, strategy: 'deeplink' };
1016
1041
  }
1017
1042
  catch (err) {
@@ -1035,10 +1060,10 @@ tell application "System Events"
1035
1060
  keystroke "t" using {command down}
1036
1061
  end tell`.trim();
1037
1062
  try {
1038
- await exec('osascript', ['-e', script], {
1063
+ await uiTurn(() => exec('osascript', ['-e', script], {
1039
1064
  env: { ...process.env, ...targetEnv({ workspace, sessionId: null }) },
1040
1065
  timeout: 15000
1041
- });
1066
+ }));
1042
1067
  return { ok: true, strategy: 'applescript' };
1043
1068
  }
1044
1069
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.30.2",
3
+ "version": "1.30.3",
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.",