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/assets/index-B2SjHT-B.js +41 -0
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/dist-node/src/config.js +8 -1
- package/dist-node/src/firstprompt.js +198 -0
- package/dist-node/src/server.js +81 -51
- package/dist-node/src/writes.js +34 -9
- package/package.json +1 -1
- package/dist/assets/index-Bzpilddl.js +0 -43
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-
|
|
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
|
|
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\//]}))});
|
package/dist-node/src/config.js
CHANGED
|
@@ -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(
|
|
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
|
+
}
|
package/dist-node/src/server.js
CHANGED
|
@@ -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
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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 },
|
|
102
|
-
if (!result.ok)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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)
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
package/dist-node/src/writes.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.",
|