conductor-remote 1.92.1 → 1.93.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.
- package/README.md +16 -10
- package/dist/assets/index-C6Qmiemg.css +1 -0
- package/dist/assets/index-xBVLsFw9.js +48 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/src/conductor.applescript +86 -4
- package/dist-node/src/db.js +40 -5
- package/dist-node/src/dev-server.js +26 -3
- package/dist-node/src/fork-workspace.js +181 -0
- package/dist-node/src/mcp-tools.js +15 -8
- package/dist-node/src/routes.js +1 -1
- package/dist-node/src/run-configs.js +337 -0
- package/dist-node/src/search-worker.js +269 -0
- package/dist-node/src/search.js +102 -209
- package/dist-node/src/server.js +144 -24
- package/dist-node/src/writes.js +17 -6
- package/package.json +1 -1
- package/dist/assets/index-DFclyO59.css +0 -1
- package/dist/assets/index-DUZ2ZaW9.js +0 -48
package/dist-node/src/search.js
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
import
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { DatabaseSync } from 'node:sqlite';
|
|
1
|
+
import { Worker } from 'node:worker_threads';
|
|
4
2
|
import { chatCursor } from "./chat-cursor.js";
|
|
5
|
-
import { HIT_CLOSE, HIT_OPEN } from "./shared.js";
|
|
6
|
-
import { parseMessage } from "./transcript.js";
|
|
7
3
|
/**
|
|
8
4
|
* Full-text search over the chat history, in a sidecar DB the relay owns.
|
|
9
5
|
*
|
|
@@ -22,7 +18,7 @@ import { parseMessage } from "./transcript.js";
|
|
|
22
18
|
* together. Skipping it was the original cut and it was wrong: the chat view
|
|
23
19
|
* *renders* thinking, so a hit there opens to something you can read, and the
|
|
24
20
|
* reasoning is where a decision gets explained before the reply summarises it.
|
|
25
|
-
* Note the shape trap behind the source query
|
|
21
|
+
* Note the shape trap behind the worker's source query: **every thinking block sits
|
|
26
22
|
* in a row with no text block beside it** (0 of 102,773 rows carry both), so a
|
|
27
23
|
* prefilter written for `"type":"text"` excludes 100% of thinking rather than
|
|
28
24
|
* some of it — the bug that made this cut invisible.
|
|
@@ -43,20 +39,6 @@ import { parseMessage } from "./transcript.js";
|
|
|
43
39
|
* stays that way; this opens its own file under the relay's state dir, and it is
|
|
44
40
|
* disposable — delete it and the next start rebuilds it.
|
|
45
41
|
*/
|
|
46
|
-
/** Bump to force a rebuild: a tokenizer or extraction change makes every stored chunk wrong. */
|
|
47
|
-
const SCHEMA_VERSION = 2;
|
|
48
|
-
/**
|
|
49
|
-
* Source rows advanced per tick. The cursor moves by *scanned* rowid rather than
|
|
50
|
-
* matched rowid, so a caught-up index re-scans nothing — get that wrong and every
|
|
51
|
-
* idle poll re-reads the 3 GB tail looking for rows it already rejected.
|
|
52
|
-
*/
|
|
53
|
-
const WINDOW_ROWS = 4000;
|
|
54
|
-
/** Yield to the event loop between batches: the backfill is ~19ms of blocking work per window. */
|
|
55
|
-
const BACKFILL_PAUSE_MS = 5;
|
|
56
|
-
/** Once caught up, look for new messages at about the rate a chat produces them. */
|
|
57
|
-
const IDLE_POLL_MS = 15_000;
|
|
58
|
-
/** A pathological single message can't be allowed to dominate the index. */
|
|
59
|
-
const MAX_CHUNK_CHARS = 64_000;
|
|
60
42
|
/** How many chunks a query ranks before they are folded into workspaces. */
|
|
61
43
|
const CHUNK_LIMIT = 300;
|
|
62
44
|
/**
|
|
@@ -67,8 +49,6 @@ const CHUNK_LIMIT = 300;
|
|
|
67
49
|
* must not render them literally.
|
|
68
50
|
*/
|
|
69
51
|
export { HIT_CLOSE, HIT_OPEN } from "./shared.js";
|
|
70
|
-
/** The `TranscriptEntry` roles this index keeps, and the set `search()` maps a stored role back through. */
|
|
71
|
-
const INDEXED_ROLES = new Set(['user', 'assistant', 'thinking']);
|
|
72
52
|
/**
|
|
73
53
|
* Turn a phone query into an FTS5 MATCH expression.
|
|
74
54
|
*
|
|
@@ -135,213 +115,126 @@ export function matchQuery(raw) {
|
|
|
135
115
|
}
|
|
136
116
|
/** The tokens `matchQuery` will search for — what a caller matches names against. */
|
|
137
117
|
export { queryTokens } from "./shared.js";
|
|
118
|
+
/**
|
|
119
|
+
* Main-thread facade for the disposable full-text index.
|
|
120
|
+
*
|
|
121
|
+
* `node:sqlite` is synchronous. Keeping its connection here meant a contended
|
|
122
|
+
* sidecar write, a backfill batch, or an expensive FTS rank stopped every HTTP
|
|
123
|
+
* route even though Conductor's AppleScript process itself is asynchronous. The
|
|
124
|
+
* worker owns both SQLite handles now; only small structured-clone messages cross
|
|
125
|
+
* back to the server thread.
|
|
126
|
+
*/
|
|
138
127
|
export class SearchIndex {
|
|
139
|
-
|
|
128
|
+
sourceDbPath;
|
|
140
129
|
file;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
this.source = source;
|
|
130
|
+
worker = null;
|
|
131
|
+
indexStatus = { chunks: 0, ready: false, progress: 0 };
|
|
132
|
+
nextId = 1;
|
|
133
|
+
pending = new Map();
|
|
134
|
+
stopping = false;
|
|
135
|
+
constructor(sourceDbPath, file) {
|
|
136
|
+
this.sourceDbPath = sourceDbPath;
|
|
149
137
|
this.file = file;
|
|
150
138
|
}
|
|
151
|
-
/**
|
|
139
|
+
/** Spawn the index worker. Search remains a convenience: startup failure is non-fatal. */
|
|
152
140
|
start() {
|
|
141
|
+
if (this.worker)
|
|
142
|
+
return;
|
|
143
|
+
this.stopping = false;
|
|
144
|
+
this.indexStatus = { chunks: 0, ready: false, progress: 0 };
|
|
145
|
+
const module = import.meta.url.endsWith('.ts') ? './search-worker.ts' : './search-worker.js';
|
|
153
146
|
try {
|
|
154
|
-
|
|
147
|
+
const worker = new Worker(new URL(module, import.meta.url), {
|
|
148
|
+
workerData: { sourceDbPath: this.sourceDbPath, file: this.file },
|
|
149
|
+
// The CLI suppresses node:sqlite's still-experimental warning in the main
|
|
150
|
+
// isolate; warning state does not cross into a worker.
|
|
151
|
+
execArgv: [...process.execArgv, '--disable-warning=ExperimentalWarning']
|
|
152
|
+
});
|
|
153
|
+
this.worker = worker;
|
|
154
|
+
worker.on('message', message => this.onMessage(message));
|
|
155
|
+
worker.on('error', error => {
|
|
156
|
+
if (this.worker === worker)
|
|
157
|
+
this.workerFailed(error.message);
|
|
158
|
+
});
|
|
159
|
+
worker.on('exit', code => {
|
|
160
|
+
if (this.worker !== worker)
|
|
161
|
+
return;
|
|
162
|
+
this.worker = null;
|
|
163
|
+
if (!this.stopping && !this.indexStatus.error)
|
|
164
|
+
this.workerFailed(`worker exited with code ${code}`);
|
|
165
|
+
});
|
|
155
166
|
}
|
|
156
167
|
catch (err) {
|
|
157
|
-
|
|
158
|
-
// serving state, transcripts or sends. Report it on /api/search instead.
|
|
159
|
-
this.openError = err instanceof Error ? err.message : String(err);
|
|
160
|
-
console.warn(`⚠ search index unavailable (${this.openError}) — /api/search will report it`);
|
|
161
|
-
return;
|
|
168
|
+
this.workerFailed(err instanceof Error ? err.message : String(err));
|
|
162
169
|
}
|
|
163
|
-
this.schedule(0);
|
|
164
|
-
}
|
|
165
|
-
stop() {
|
|
166
|
-
if (this.timer)
|
|
167
|
-
clearTimeout(this.timer);
|
|
168
|
-
this.timer = null;
|
|
169
170
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
// tick that still fails is retried by the scheduler with nothing lost (the cursor
|
|
178
|
-
// only advances on commit).
|
|
179
|
-
db.exec('PRAGMA busy_timeout = 5000');
|
|
180
|
-
db.exec('CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL)');
|
|
181
|
-
const version = Number(this.readMeta(db, 'version') ?? 0);
|
|
182
|
-
if (version !== SCHEMA_VERSION) {
|
|
183
|
-
db.exec('DROP TABLE IF EXISTS chunks');
|
|
184
|
-
db.exec(`
|
|
185
|
-
CREATE VIRTUAL TABLE chunks USING fts5(
|
|
186
|
-
body,
|
|
187
|
-
session_id UNINDEXED,
|
|
188
|
-
src_rowid UNINDEXED,
|
|
189
|
-
role UNINDEXED,
|
|
190
|
-
at UNINDEXED,
|
|
191
|
-
tokenize='porter unicode61'
|
|
192
|
-
)
|
|
193
|
-
`);
|
|
194
|
-
db.prepare('INSERT OR REPLACE INTO meta(k, v) VALUES (?, ?)').run('version', String(SCHEMA_VERSION));
|
|
195
|
-
db.prepare('INSERT OR REPLACE INTO meta(k, v) VALUES (?, ?)').run('cursor', '0');
|
|
196
|
-
if (version)
|
|
197
|
-
console.log(`search index schema ${version} → ${SCHEMA_VERSION}, rebuilding`);
|
|
198
|
-
}
|
|
199
|
-
this.db = db;
|
|
200
|
-
this.cursor = Number(this.readMeta(db, 'cursor') ?? 0);
|
|
171
|
+
async stop() {
|
|
172
|
+
this.stopping = true;
|
|
173
|
+
const worker = this.worker;
|
|
174
|
+
this.worker = null;
|
|
175
|
+
this.rejectPending('search index stopped');
|
|
176
|
+
if (worker)
|
|
177
|
+
await worker.terminate();
|
|
201
178
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
return row?.v ?? null;
|
|
179
|
+
status() {
|
|
180
|
+
return { ...this.indexStatus };
|
|
205
181
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
182
|
+
/**
|
|
183
|
+
* Top matching chunks, best first. The SQLite work happens entirely in the
|
|
184
|
+
* worker, so awaiting a slow rank does not stop unrelated API requests.
|
|
185
|
+
*/
|
|
186
|
+
search(raw, options = {}) {
|
|
187
|
+
if (!matchQuery(raw) || (options.sessionIds && !options.sessionIds.length))
|
|
188
|
+
return Promise.resolve([]);
|
|
189
|
+
const worker = this.worker;
|
|
190
|
+
if (!worker || this.indexStatus.error)
|
|
191
|
+
return Promise.resolve([]);
|
|
192
|
+
const id = this.nextId++;
|
|
193
|
+
return new Promise((resolve, reject) => {
|
|
194
|
+
this.pending.set(id, { resolve, reject });
|
|
211
195
|
try {
|
|
212
|
-
|
|
196
|
+
worker.postMessage({
|
|
197
|
+
id,
|
|
198
|
+
type: 'search',
|
|
199
|
+
raw,
|
|
200
|
+
options: { limit: CHUNK_LIMIT, ...options }
|
|
201
|
+
});
|
|
213
202
|
}
|
|
214
|
-
catch (
|
|
215
|
-
|
|
203
|
+
catch (error) {
|
|
204
|
+
this.pending.delete(id);
|
|
205
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
216
206
|
}
|
|
217
|
-
|
|
218
|
-
}, ms);
|
|
219
|
-
this.timer.unref?.();
|
|
207
|
+
});
|
|
220
208
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
* advances past rows that hold nothing worth indexing. Filtering first and
|
|
226
|
-
* advancing to the last match instead would leave a caught-up index re-scanning
|
|
227
|
-
* every tool_result between the last prose row and the end of the table, every
|
|
228
|
-
* poll, forever.
|
|
229
|
-
*/
|
|
230
|
-
tick() {
|
|
231
|
-
const db = this.db;
|
|
232
|
-
if (!db)
|
|
233
|
-
return false;
|
|
234
|
-
const window = this.source.query('SELECT rowid FROM session_messages WHERE rowid > ? ORDER BY rowid LIMIT ?', [this.cursor, WINDOW_ROWS]);
|
|
235
|
-
if (!window.length) {
|
|
236
|
-
this.caughtUp = true;
|
|
237
|
-
return false;
|
|
238
|
-
}
|
|
239
|
-
const end = window[window.length - 1].rowid;
|
|
240
|
-
// Only rows that can hold prose: a plain-text prompt, or a frame carrying a text or
|
|
241
|
-
// thinking block. Everything else is tool plumbing and the bulk of the bytes.
|
|
242
|
-
// The thinking clause is not redundant with the text one — the two block types never
|
|
243
|
-
// share a row (see this file's header), so dropping it drops thinking entirely.
|
|
244
|
-
const rows = this.source.query(`SELECT rowid, id, session_id, role, content, full_message, created_at, sent_at, queue_order
|
|
245
|
-
FROM session_messages
|
|
246
|
-
WHERE rowid > ? AND rowid <= ? AND session_id IS NOT NULL
|
|
247
|
-
AND (role = 'user' OR content LIKE '%"type":"text"%' OR content LIKE '%"type":"thinking"%')
|
|
248
|
-
ORDER BY rowid`, [this.cursor, end]);
|
|
249
|
-
const insert = db.prepare('INSERT INTO chunks(body, session_id, src_rowid, role, at) VALUES (?, ?, ?, ?, ?)');
|
|
250
|
-
db.exec('BEGIN');
|
|
251
|
-
try {
|
|
252
|
-
for (const row of rows) {
|
|
253
|
-
// Reuse the transcript parser rather than a second JSON walk: it already knows
|
|
254
|
-
// that text inside a `type:"user"` frame is injected context and not the user's
|
|
255
|
-
// words, and indexing something the chat view would never show is how a search
|
|
256
|
-
// result becomes impossible to find once you open it.
|
|
257
|
-
for (const entry of parseMessage(row, null)) {
|
|
258
|
-
if (!INDEXED_ROLES.has(entry.role))
|
|
259
|
-
continue;
|
|
260
|
-
const body = entry.text.trim();
|
|
261
|
-
if (!body)
|
|
262
|
-
continue;
|
|
263
|
-
insert.run(body.slice(0, MAX_CHUNK_CHARS), row.session_id, row.rowid, entry.role, entry.ts);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
db.prepare('INSERT OR REPLACE INTO meta(k, v) VALUES (?, ?)').run('cursor', String(end));
|
|
267
|
-
db.exec('COMMIT');
|
|
209
|
+
onMessage(message) {
|
|
210
|
+
if (message.type === 'status') {
|
|
211
|
+
this.indexStatus = message.status;
|
|
212
|
+
return;
|
|
268
213
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
214
|
+
if (message.type === 'log') {
|
|
215
|
+
console[message.level](message.message);
|
|
216
|
+
return;
|
|
272
217
|
}
|
|
273
|
-
|
|
274
|
-
|
|
218
|
+
const pending = this.pending.get(message.id);
|
|
219
|
+
if (!pending)
|
|
220
|
+
return;
|
|
221
|
+
this.pending.delete(message.id);
|
|
222
|
+
if (message.type === 'result')
|
|
223
|
+
pending.resolve(message.hits);
|
|
224
|
+
else
|
|
225
|
+
pending.reject(new Error(message.error));
|
|
275
226
|
}
|
|
276
|
-
|
|
277
|
-
if (this.
|
|
278
|
-
return
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const chunks = Number(db.prepare('SELECT COUNT(*) c FROM chunks').get().c);
|
|
283
|
-
if (this.caughtUp)
|
|
284
|
-
return { chunks, ready: true, progress: 1 };
|
|
285
|
-
// Only re-read the source's high-water mark while backfilling; it costs a query
|
|
286
|
-
// and the answer only matters for the progress bar.
|
|
287
|
-
if (!this.sourceMax) {
|
|
288
|
-
const max = this.source.query('SELECT MAX(rowid) m FROM session_messages')[0]?.m;
|
|
289
|
-
this.sourceMax = max ?? 0;
|
|
290
|
-
}
|
|
291
|
-
const progress = this.sourceMax ? Math.min(1, this.cursor / this.sourceMax) : 0;
|
|
292
|
-
return { chunks, ready: false, progress };
|
|
227
|
+
workerFailed(error) {
|
|
228
|
+
if (this.indexStatus.error === error)
|
|
229
|
+
return;
|
|
230
|
+
this.indexStatus = { chunks: 0, ready: false, progress: 0, error };
|
|
231
|
+
this.rejectPending(error);
|
|
232
|
+
console.warn(`⚠ search index unavailable (${error}) — /api/search will report it`);
|
|
293
233
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
* resolved to chat ids by the caller because this index knows nothing about
|
|
299
|
-
* workspaces. It has to sit inside the query rather than be applied to the
|
|
300
|
-
* chunks it returns, because the `limit` is spent before any post-filter runs
|
|
301
|
-
* — a common word fills all 300 slots from the busiest repo and a smaller one
|
|
302
|
-
* folds up to nothing. Measured on this Mac's 205k chunks with the largest repo's
|
|
303
|
-
* 1,005 chats as the list: +0.3ms on a rare word, +60ms on the worst common-word
|
|
304
|
-
* query (165ms → 220ms), still under the phone's 250ms debounce. The list rides
|
|
305
|
-
* in as one JSON parameter through `json_each`, so its length never meets
|
|
306
|
-
* SQLite's bound-variable limit. An empty list matches nothing, which is the
|
|
307
|
-
* right answer for a repo with no chats and never "everything".
|
|
308
|
-
*/
|
|
309
|
-
search(raw, { limit = CHUNK_LIMIT, sessionIds } = {}) {
|
|
310
|
-
const db = this.db;
|
|
311
|
-
if (!db)
|
|
312
|
-
return [];
|
|
313
|
-
const match = matchQuery(raw);
|
|
314
|
-
if (!match)
|
|
315
|
-
return [];
|
|
316
|
-
if (sessionIds && !sessionIds.length)
|
|
317
|
-
return [];
|
|
318
|
-
const scope = sessionIds ? 'AND session_id IN (SELECT value FROM json_each(?))' : '';
|
|
319
|
-
const params = sessionIds
|
|
320
|
-
? [HIT_OPEN, HIT_CLOSE, match, JSON.stringify(sessionIds), limit]
|
|
321
|
-
: [HIT_OPEN, HIT_CLOSE, match, limit];
|
|
322
|
-
let rows;
|
|
323
|
-
try {
|
|
324
|
-
rows = db
|
|
325
|
-
.prepare(`SELECT session_id, src_rowid, role, at, -bm25(chunks) AS score,
|
|
326
|
-
snippet(chunks, 0, ?, ?, '…', 24) AS snippet
|
|
327
|
-
FROM chunks WHERE chunks MATCH ? ${scope} ORDER BY bm25(chunks) LIMIT ?`)
|
|
328
|
-
.all(...params);
|
|
329
|
-
}
|
|
330
|
-
catch (err) {
|
|
331
|
-
// A MATCH that still fails to parse is a bug in matchQuery, not user error —
|
|
332
|
-
// report it rather than showing an empty result that looks like "no matches".
|
|
333
|
-
throw new Error(`search failed for ${JSON.stringify(match)}: ${err instanceof Error ? err.message : err}`);
|
|
334
|
-
}
|
|
335
|
-
return rows.map(r => ({
|
|
336
|
-
sessionId: r.session_id,
|
|
337
|
-
srcRowid: Number(r.src_rowid),
|
|
338
|
-
// An index written before SCHEMA_VERSION 2 is dropped on open, so an unknown role
|
|
339
|
-
// here is a bug rather than an old row — fall back to the neutral one.
|
|
340
|
-
role: INDEXED_ROLES.has(r.role) ? r.role : 'assistant',
|
|
341
|
-
at: r.at,
|
|
342
|
-
score: Number(r.score),
|
|
343
|
-
snippet: r.snippet
|
|
344
|
-
}));
|
|
234
|
+
rejectPending(message) {
|
|
235
|
+
for (const pending of this.pending.values())
|
|
236
|
+
pending.reject(new Error(message));
|
|
237
|
+
this.pending.clear();
|
|
345
238
|
}
|
|
346
239
|
}
|
|
347
240
|
const SNIPPETS_PER_RESULT = 3;
|
package/dist-node/src/server.js
CHANGED
|
@@ -13,6 +13,7 @@ import { ConductorDb } from "./db.js";
|
|
|
13
13
|
import { DevServerController } from "./dev-server.js";
|
|
14
14
|
import { isAllowedPreviewPath, parseFileReference } from "./file-preview.js";
|
|
15
15
|
import { FirstPromptQueue } from "./firstprompt.js";
|
|
16
|
+
import { captureForkWorkspace, materializeForkWorkspace, releaseForkWorkspace } from "./fork-workspace.js";
|
|
16
17
|
import { startFunnelWatchdog } from "./funnel-watchdog.js";
|
|
17
18
|
import { listSourceFiles, workspaceDiff } from "./git.js";
|
|
18
19
|
import { installLogCapture, isManaged, LOG_FILE_NAMES, logFiles, processStartedAt, recentLogs, redactSecrets, tailLogFile } from "./logbuf.js";
|
|
@@ -53,7 +54,7 @@ const planUsage = new PlanUsageService();
|
|
|
53
54
|
// Full-text index over the chat prose, in the relay's own sidecar DB — never in
|
|
54
55
|
// Conductor's (see src/search.ts). It backfills in the background and is disposable:
|
|
55
56
|
// deleting the file rebuilds it on the next start.
|
|
56
|
-
const search = new SearchIndex(
|
|
57
|
+
const search = new SearchIndex(cfg.dbPath, path.join(stateDir(), 'search.db'));
|
|
57
58
|
search.start();
|
|
58
59
|
/**
|
|
59
60
|
* The MCP tools, bound to this relay over loopback.
|
|
@@ -94,6 +95,41 @@ const mcpTools = createTools(async (route, opts = {}) => {
|
|
|
94
95
|
// the phone opening the app and the send landing.
|
|
95
96
|
setRestartGuard(() => !reads.listWorkspaces().some(w => w.session_status === 'working'));
|
|
96
97
|
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
98
|
+
/**
|
|
99
|
+
* A deep link has no request id to correlate with the workspace row it creates. Keep
|
|
100
|
+
* relay-originated creations single-flight until that row appears, or two simultaneous
|
|
101
|
+
* requests can each claim the other's workspace. Manual desktop creation can still
|
|
102
|
+
* happen in the gap, so callers also narrow the fresh row to the requested repo.
|
|
103
|
+
*/
|
|
104
|
+
let workspaceCreationTail = Promise.resolve();
|
|
105
|
+
async function createWorkspaceAndRead(prompt, repoPath, repoName) {
|
|
106
|
+
const previous = workspaceCreationTail;
|
|
107
|
+
let release = () => { };
|
|
108
|
+
workspaceCreationTail = new Promise(resolve => {
|
|
109
|
+
release = resolve;
|
|
110
|
+
});
|
|
111
|
+
await previous;
|
|
112
|
+
try {
|
|
113
|
+
const before = new Set(reads.listWorkspaces().map(w => w.id));
|
|
114
|
+
const result = await createWorkspace(prompt, repoPath);
|
|
115
|
+
if (!result.ok)
|
|
116
|
+
return { result };
|
|
117
|
+
// The deep link is fire-and-forget, so the new row is the only proof it worked.
|
|
118
|
+
// Creating a worktree takes a beat longer than opening a chat does.
|
|
119
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
120
|
+
await sleep(500);
|
|
121
|
+
const created = reads
|
|
122
|
+
.listWorkspaces()
|
|
123
|
+
.find(workspace => !before.has(workspace.id) && (!repoName || workspace.repo_name === repoName));
|
|
124
|
+
if (created)
|
|
125
|
+
return { result, created };
|
|
126
|
+
}
|
|
127
|
+
return { result };
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
release();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
97
133
|
/**
|
|
98
134
|
* Has Conductor taken ownership of the prompt yet? The receipt everything below is
|
|
99
135
|
* built on. The AppleScript actuator reports `ok` on `osascript` exit 0 — which only
|
|
@@ -879,7 +915,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
879
915
|
const scope = scoped
|
|
880
916
|
? { sessionIds: reads.searchSessionIds(repos.length ? repos : undefined, includeArchived) }
|
|
881
917
|
: {};
|
|
882
|
-
const hits = search.search(q, scope);
|
|
918
|
+
const hits = await search.search(q, scope);
|
|
883
919
|
const targets = reads.searchTargets([...new Set(hits.map(h => h.sessionId))]);
|
|
884
920
|
const fromChats = foldHits(hits, sid => {
|
|
885
921
|
const workspace = targets.get(sid)?.workspace ?? null;
|
|
@@ -1188,17 +1224,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1188
1224
|
return json(req, res, 404, { error: `unknown repo ${body.repo}` });
|
|
1189
1225
|
if (repo && !repo.root_path)
|
|
1190
1226
|
return json(req, res, 409, { error: `${repo.name} has no checkout path` });
|
|
1191
|
-
const
|
|
1192
|
-
const result = await createWorkspace(prompt, repo?.root_path ?? null);
|
|
1227
|
+
const { result, created } = await createWorkspaceAndRead(prompt, repo?.root_path ?? null, repo?.name);
|
|
1193
1228
|
if (!result.ok)
|
|
1194
1229
|
return json(req, res, 502, result);
|
|
1195
|
-
// The deep link is fire-and-forget, so the new row is the only proof it worked.
|
|
1196
|
-
// Creating a worktree takes a beat longer than opening a chat does.
|
|
1197
|
-
let created;
|
|
1198
|
-
for (let attempt = 0; attempt < 40 && !created; attempt++) {
|
|
1199
|
-
await sleep(500);
|
|
1200
|
-
created = reads.listWorkspaces().find(w => !before.has(w.id));
|
|
1201
|
-
}
|
|
1202
1230
|
if (!created) {
|
|
1203
1231
|
return json(req, res, 502, {
|
|
1204
1232
|
ok: false,
|
|
@@ -1463,9 +1491,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1463
1491
|
}
|
|
1464
1492
|
return json(req, res, 200, { ok: true, strategy: result.strategy, workspace: archived });
|
|
1465
1493
|
}
|
|
1466
|
-
//
|
|
1467
|
-
//
|
|
1468
|
-
//
|
|
1494
|
+
// Conductor's Run configs plus tailnet-only HTTPS forwards for the active
|
|
1495
|
+
// one's ports. Reads never touch Conductor's UI; start/stop use the same
|
|
1496
|
+
// Accessibility lock and target assertion as every other UI write.
|
|
1469
1497
|
const devServerOf = routeParam(routes.devServer, req.method, pathname);
|
|
1470
1498
|
if (devServerOf) {
|
|
1471
1499
|
const ws = reads.getWorkspace(devServerOf);
|
|
@@ -1478,7 +1506,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1478
1506
|
const ws = reads.getWorkspace(startDevServerIn);
|
|
1479
1507
|
if (!ws)
|
|
1480
1508
|
return json(req, res, 404, { error: 'workspace not found' });
|
|
1481
|
-
const
|
|
1509
|
+
const body = JSON.parse((await readBody(req)) || '{}');
|
|
1510
|
+
if (body.runConfigId !== undefined && (typeof body.runConfigId !== 'string' || !body.runConfigId.trim())) {
|
|
1511
|
+
return json(req, res, 400, { error: 'runConfigId must be a non-empty string' });
|
|
1512
|
+
}
|
|
1513
|
+
const result = await devServers.start(ws, body.runConfigId);
|
|
1482
1514
|
return json(req, res, result.ok ? 200 : result.available ? 502 : 409, result);
|
|
1483
1515
|
}
|
|
1484
1516
|
const stopDevServerIn = routeParam(routes.stopDevServer, req.method, pathname);
|
|
@@ -1811,11 +1843,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
1811
1843
|
return json(req, res, answer.status, answer.body);
|
|
1812
1844
|
}
|
|
1813
1845
|
// POST /api/sessions/:id/split
|
|
1814
|
-
// { prompt?, includeThinking?, includeTools?, throughRowid?, onlyRowid? }
|
|
1846
|
+
// { prompt?, includeThinking?, includeTools?, throughRowid?, onlyRowid?, destination? }
|
|
1815
1847
|
//
|
|
1816
|
-
// Conductor's own
|
|
1817
|
-
//
|
|
1818
|
-
//
|
|
1848
|
+
// Conductor's own tab fork resumes the agent's real session. This copies the
|
|
1849
|
+
// conversation instead, as a Conductor attachment, which is the cut that survives
|
|
1850
|
+
// being read by a *different* agent: prose and reasoning, no tool churn. Its
|
|
1851
|
+
// destination can be another tab over the same files, or a new workspace whose
|
|
1852
|
+
// Git layers are restored from the source's current worktree snapshot.
|
|
1819
1853
|
// Two reasons it exists at all. A tangent asked inside a running chat leaves three
|
|
1820
1854
|
// conversations interleaved in one tab, which reads badly for everyone afterwards;
|
|
1821
1855
|
// and Conductor's fork lives on a hover menu over one message, which an agent
|
|
@@ -1823,9 +1857,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
1823
1857
|
// gets more expensive the longer the chat is.
|
|
1824
1858
|
//
|
|
1825
1859
|
// It stops before sending. The composed prompt goes out through the ordinary send
|
|
1826
|
-
// route so it inherits the retry loop, the transcript confirm and the parked queue
|
|
1827
|
-
//
|
|
1828
|
-
//
|
|
1860
|
+
// route so it inherits the retry loop, the transcript confirm and the parked queue.
|
|
1861
|
+
// For a tab, that also keeps ⌘T plus a send from becoming two UI turns inside one
|
|
1862
|
+
// request (28s + 55s against the MCP client's 75s); for a workspace it leaves the
|
|
1863
|
+
// staged handoff as the same editable draft the phone already presents for a tab.
|
|
1829
1864
|
const splitFrom = routeParam(routes.splitChat, req.method, pathname);
|
|
1830
1865
|
if (splitFrom) {
|
|
1831
1866
|
const sessionId = splitFrom;
|
|
@@ -1842,6 +1877,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
1842
1877
|
const source = reads.listSessions(ws.id).find(s => s.id === sessionId);
|
|
1843
1878
|
if (!source)
|
|
1844
1879
|
return json(req, res, 404, { error: 'chat not found in that workspace' });
|
|
1880
|
+
const destination = body.destination ?? 'chat';
|
|
1881
|
+
if (destination !== 'chat' && destination !== 'workspace') {
|
|
1882
|
+
return json(req, res, 400, { error: 'destination must be chat or workspace' });
|
|
1883
|
+
}
|
|
1845
1884
|
const format = { thinking: body.includeThinking !== false, tools: body.includeTools === true };
|
|
1846
1885
|
const { entries } = reads.getMessages(sessionId);
|
|
1847
1886
|
const through = body.throughRowid;
|
|
@@ -1888,10 +1927,90 @@ const server = http.createServer(async (req, res) => {
|
|
|
1888
1927
|
'',
|
|
1889
1928
|
''
|
|
1890
1929
|
].join('\n');
|
|
1891
|
-
const
|
|
1930
|
+
const transcript = header + rendered.text;
|
|
1931
|
+
if (destination === 'workspace') {
|
|
1932
|
+
if (!(ws.repo_name && ws.repo_root)) {
|
|
1933
|
+
return json(req, res, 409, { error: 'the source workspace has no repository checkout to fork' });
|
|
1934
|
+
}
|
|
1935
|
+
let snapshot;
|
|
1936
|
+
try {
|
|
1937
|
+
snapshot = await captureForkWorkspace(ws.worktree);
|
|
1938
|
+
}
|
|
1939
|
+
catch (err) {
|
|
1940
|
+
const reason = err instanceof Error ? err.message : 'Git could not capture the worktree';
|
|
1941
|
+
return json(req, res, 502, { error: `Could not snapshot the source workspace: ${reason}` });
|
|
1942
|
+
}
|
|
1943
|
+
let staged;
|
|
1944
|
+
let materialized = false;
|
|
1945
|
+
let created;
|
|
1946
|
+
try {
|
|
1947
|
+
staged = stageAttachment(STAGED_ATTACHMENTS_DIR, `Transcript of ${title}.md`, Buffer.from(transcript));
|
|
1948
|
+
const creation = await createWorkspaceAndRead('', ws.repo_root, ws.repo_name);
|
|
1949
|
+
if (!creation.result.ok)
|
|
1950
|
+
return json(req, res, 502, creation.result);
|
|
1951
|
+
created = creation.created;
|
|
1952
|
+
if (!created) {
|
|
1953
|
+
return json(req, res, 502, {
|
|
1954
|
+
error: 'Conductor didn’t create the fork workspace — check it’s running and not showing a dialog.'
|
|
1955
|
+
});
|
|
1956
|
+
}
|
|
1957
|
+
// The DB row can precede `.git` by a tick. Install the snapshot at the
|
|
1958
|
+
// first verified worktree path, before Conductor starts the new agent.
|
|
1959
|
+
let target = reads.getWorkspace(created.id) ?? created;
|
|
1960
|
+
for (let attempt = 0; attempt < 20 && !target.worktree; attempt++) {
|
|
1961
|
+
await sleep(250);
|
|
1962
|
+
target = reads.getWorkspace(created.id) ?? target;
|
|
1963
|
+
}
|
|
1964
|
+
if (!target.worktree)
|
|
1965
|
+
throw new Error('the new workspace worktree path never became available');
|
|
1966
|
+
await materializeForkWorkspace(snapshot, target.worktree);
|
|
1967
|
+
materializeStagedAttachments(STAGED_ATTACHMENTS_DIR, target.worktree, [staged.stageId]);
|
|
1968
|
+
materialized = true;
|
|
1969
|
+
discardStagedAttachment(STAGED_ATTACHMENTS_DIR, staged.stageId);
|
|
1970
|
+
let destinationSession = reads.listSessions(created.id)[0];
|
|
1971
|
+
for (let attempt = 0; attempt < 12 && !destinationSession; attempt++) {
|
|
1972
|
+
await sleep(250);
|
|
1973
|
+
destinationSession = reads.listSessions(created.id)[0];
|
|
1974
|
+
}
|
|
1975
|
+
return json(req, res, 200, {
|
|
1976
|
+
ok: true,
|
|
1977
|
+
destination,
|
|
1978
|
+
sessionId: destinationSession?.id ?? null,
|
|
1979
|
+
workspaceId: created.id,
|
|
1980
|
+
text: attachmentPrompt(staged.token, body.prompt),
|
|
1981
|
+
attachment: {
|
|
1982
|
+
name: staged.name,
|
|
1983
|
+
path: staged.path,
|
|
1984
|
+
bytes: staged.bytes,
|
|
1985
|
+
kept: rendered.kept,
|
|
1986
|
+
elided
|
|
1987
|
+
}
|
|
1988
|
+
});
|
|
1989
|
+
}
|
|
1990
|
+
catch (err) {
|
|
1991
|
+
const reason = err instanceof Error ? err.message : 'the current files could not be copied';
|
|
1992
|
+
return json(req, res, 502, {
|
|
1993
|
+
error: created
|
|
1994
|
+
? `Workspace ${created.id} was created, but its code fork failed: ${reason}`
|
|
1995
|
+
: `Could not create the code fork: ${reason}`
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1998
|
+
finally {
|
|
1999
|
+
if (staged && !materialized)
|
|
2000
|
+
discardStagedAttachment(STAGED_ATTACHMENTS_DIR, staged.stageId);
|
|
2001
|
+
await releaseForkWorkspace(snapshot).catch(err => {
|
|
2002
|
+
console.warn(`[relay] could not release fork snapshot ${snapshot.ref}: ${err instanceof Error ? err.message : err}`);
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
const attachment = writeAttachment(ws.worktree, `Transcript of ${title}.md`, transcript);
|
|
1892
2007
|
const opened = await openChat(ws);
|
|
1893
2008
|
if ('error' in opened) {
|
|
1894
|
-
return json(req, res, 502, {
|
|
2009
|
+
return json(req, res, 502, {
|
|
2010
|
+
...opened.result,
|
|
2011
|
+
destination,
|
|
2012
|
+
attachment: { ...attachment, ...rendered, elided }
|
|
2013
|
+
});
|
|
1895
2014
|
}
|
|
1896
2015
|
// The token is what Conductor turns into the attachment chip and supplies to the
|
|
1897
2016
|
// receiving agent. Do not repeat `attachment.relPath` in prose: that renders a
|
|
@@ -1899,6 +2018,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1899
2018
|
const text = attachmentPrompt(attachment.token, body.prompt);
|
|
1900
2019
|
return json(req, res, 200, {
|
|
1901
2020
|
ok: true,
|
|
2021
|
+
destination,
|
|
1902
2022
|
sessionId: opened.sessionId,
|
|
1903
2023
|
workspaceId: ws.id,
|
|
1904
2024
|
text,
|