conductor-remote 1.92.2 → 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/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/server.js +142 -22
- 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-DnnHEVDt.js +0 -48
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const fileCache = new Map();
|
|
5
|
+
/** Remove a TOML comment without treating a `#` inside a quoted string as one. */
|
|
6
|
+
function withoutComment(line) {
|
|
7
|
+
let quote = null;
|
|
8
|
+
let escaped = false;
|
|
9
|
+
for (let i = 0; i < line.length; i++) {
|
|
10
|
+
const char = line[i];
|
|
11
|
+
if (quote === '"' && escaped) {
|
|
12
|
+
escaped = false;
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (quote === '"' && char === '\\') {
|
|
16
|
+
escaped = true;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (quote) {
|
|
20
|
+
if (char === quote)
|
|
21
|
+
quote = null;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (char === '"' || char === "'")
|
|
25
|
+
quote = char;
|
|
26
|
+
else if (char === '#')
|
|
27
|
+
return line.slice(0, i);
|
|
28
|
+
}
|
|
29
|
+
return line;
|
|
30
|
+
}
|
|
31
|
+
/** Strip comments from a value that can span several physical TOML lines. */
|
|
32
|
+
function withoutComments(value) {
|
|
33
|
+
return value
|
|
34
|
+
.split(/\r?\n/)
|
|
35
|
+
.map(line => withoutComment(line))
|
|
36
|
+
.join('\n');
|
|
37
|
+
}
|
|
38
|
+
function tomlString(raw) {
|
|
39
|
+
const value = withoutComment(raw).trim();
|
|
40
|
+
if (value.startsWith("'") && value.endsWith("'") && !value.startsWith("'''"))
|
|
41
|
+
return value.slice(1, -1);
|
|
42
|
+
if (!(value.startsWith('"') && value.endsWith('"')) || value.startsWith('"""'))
|
|
43
|
+
return null;
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(value);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Split a TOML dotted key, retaining dots inside quoted components. */
|
|
52
|
+
function dottedKey(raw) {
|
|
53
|
+
const parts = [];
|
|
54
|
+
let start = 0;
|
|
55
|
+
let quote = null;
|
|
56
|
+
let escaped = false;
|
|
57
|
+
for (let i = 0; i < raw.length; i++) {
|
|
58
|
+
const char = raw[i];
|
|
59
|
+
if (quote === '"' && escaped) {
|
|
60
|
+
escaped = false;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (quote === '"' && char === '\\') {
|
|
64
|
+
escaped = true;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (quote) {
|
|
68
|
+
if (char === quote)
|
|
69
|
+
quote = null;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (char === '"' || char === "'")
|
|
73
|
+
quote = char;
|
|
74
|
+
else if (char === '.') {
|
|
75
|
+
parts.push(raw.slice(start, i));
|
|
76
|
+
start = i + 1;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (quote)
|
|
80
|
+
return null;
|
|
81
|
+
parts.push(raw.slice(start));
|
|
82
|
+
const decoded = parts.map(part => {
|
|
83
|
+
const value = part.trim();
|
|
84
|
+
if (!value)
|
|
85
|
+
return null;
|
|
86
|
+
if (value.startsWith('"') || value.startsWith("'"))
|
|
87
|
+
return tomlString(value);
|
|
88
|
+
return /^[A-Za-z0-9_-]+$/.test(value) ? value : null;
|
|
89
|
+
});
|
|
90
|
+
return decoded.every((part) => part !== null) ? decoded : null;
|
|
91
|
+
}
|
|
92
|
+
function tablePath(line) {
|
|
93
|
+
const clean = withoutComment(line).trim();
|
|
94
|
+
if (clean.startsWith('[['))
|
|
95
|
+
return null;
|
|
96
|
+
const match = clean.match(/^\[([^\]]+)]$/);
|
|
97
|
+
return match ? dottedKey(match[1]) : null;
|
|
98
|
+
}
|
|
99
|
+
function assignment(line) {
|
|
100
|
+
const clean = withoutComment(line);
|
|
101
|
+
let quote = null;
|
|
102
|
+
let escaped = false;
|
|
103
|
+
for (let i = 0; i < clean.length; i++) {
|
|
104
|
+
const char = clean[i];
|
|
105
|
+
if (quote === '"' && escaped) {
|
|
106
|
+
escaped = false;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (quote === '"' && char === '\\') {
|
|
110
|
+
escaped = true;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (quote) {
|
|
114
|
+
if (char === quote)
|
|
115
|
+
quote = null;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (char === '"' || char === "'")
|
|
119
|
+
quote = char;
|
|
120
|
+
else if (char === '=') {
|
|
121
|
+
const key = dottedKey(clean.slice(0, i));
|
|
122
|
+
if (key?.length !== 1)
|
|
123
|
+
return null;
|
|
124
|
+
return { key: key[0], value: clean.slice(i + 1).trim() };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
function multilineDelimiter(value) {
|
|
130
|
+
const trimmed = value.trimStart();
|
|
131
|
+
for (const delimiter of ['"""', "'''"]) {
|
|
132
|
+
if (!trimmed.startsWith(delimiter))
|
|
133
|
+
continue;
|
|
134
|
+
return trimmed.indexOf(delimiter, delimiter.length) < 0 ? delimiter : null;
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
function stringValue(value) {
|
|
139
|
+
if (tomlString(value) !== null)
|
|
140
|
+
return true;
|
|
141
|
+
const trimmed = withoutComments(value).trim();
|
|
142
|
+
return ((trimmed.startsWith('"""') && trimmed.indexOf('"""', 3) >= 3) ||
|
|
143
|
+
(trimmed.startsWith("'''") && trimmed.indexOf("'''", 3) >= 3));
|
|
144
|
+
}
|
|
145
|
+
function arrayOpen(value) {
|
|
146
|
+
let depth = 0;
|
|
147
|
+
let quote = null;
|
|
148
|
+
let escaped = false;
|
|
149
|
+
for (const char of withoutComments(value)) {
|
|
150
|
+
if (quote === '"' && escaped) {
|
|
151
|
+
escaped = false;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (quote === '"' && char === '\\') {
|
|
155
|
+
escaped = true;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (quote) {
|
|
159
|
+
if (char === quote)
|
|
160
|
+
quote = null;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (char === '"' || char === "'")
|
|
164
|
+
quote = char;
|
|
165
|
+
else if (char === '[')
|
|
166
|
+
depth++;
|
|
167
|
+
else if (char === ']')
|
|
168
|
+
depth--;
|
|
169
|
+
}
|
|
170
|
+
return depth > 0;
|
|
171
|
+
}
|
|
172
|
+
function stringList(raw) {
|
|
173
|
+
const scalar = tomlString(raw);
|
|
174
|
+
if (scalar !== null)
|
|
175
|
+
return [scalar];
|
|
176
|
+
const value = withoutComments(raw).trim();
|
|
177
|
+
if (!value.startsWith('[') || !value.endsWith(']'))
|
|
178
|
+
return null;
|
|
179
|
+
const values = [];
|
|
180
|
+
let start = 1;
|
|
181
|
+
let quote = null;
|
|
182
|
+
let escaped = false;
|
|
183
|
+
for (let i = 1; i < value.length - 1; i++) {
|
|
184
|
+
const char = value[i];
|
|
185
|
+
if (quote === '"' && escaped) {
|
|
186
|
+
escaped = false;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (quote === '"' && char === '\\') {
|
|
190
|
+
escaped = true;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (quote) {
|
|
194
|
+
if (char === quote)
|
|
195
|
+
quote = null;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (char === '"' || char === "'")
|
|
199
|
+
quote = char;
|
|
200
|
+
else if (char === ',') {
|
|
201
|
+
const item = tomlString(value.slice(start, i));
|
|
202
|
+
if (item === null)
|
|
203
|
+
return null;
|
|
204
|
+
values.push(item);
|
|
205
|
+
start = i + 1;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const tail = value.slice(start, -1).trim();
|
|
209
|
+
if (tail) {
|
|
210
|
+
const item = tomlString(tail);
|
|
211
|
+
if (item === null)
|
|
212
|
+
return null;
|
|
213
|
+
values.push(item);
|
|
214
|
+
}
|
|
215
|
+
return values;
|
|
216
|
+
}
|
|
217
|
+
function displayName(id) {
|
|
218
|
+
return id
|
|
219
|
+
.replace(/[-\s]+/g, ' ')
|
|
220
|
+
.trim()
|
|
221
|
+
.replace(/(^| )([a-z])/g, (_whole, prefix, letter) => `${prefix}${letter.toUpperCase()}`);
|
|
222
|
+
}
|
|
223
|
+
function parseLayer(text) {
|
|
224
|
+
const configs = new Map();
|
|
225
|
+
let kind = null;
|
|
226
|
+
let section = [];
|
|
227
|
+
let multiline = null;
|
|
228
|
+
const lines = text.split(/\r?\n/);
|
|
229
|
+
for (let i = 0; i < lines.length; i++) {
|
|
230
|
+
const line = lines[i];
|
|
231
|
+
if (multiline) {
|
|
232
|
+
if (line.includes(multiline))
|
|
233
|
+
multiline = null;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const header = tablePath(line);
|
|
237
|
+
if (header) {
|
|
238
|
+
section = header;
|
|
239
|
+
if (section.length === 3 && section[0] === 'scripts' && section[1] === 'run') {
|
|
240
|
+
kind = 'named';
|
|
241
|
+
const id = section[2];
|
|
242
|
+
if (!configs.has(id))
|
|
243
|
+
configs.set(id, { id });
|
|
244
|
+
}
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const found = assignment(line);
|
|
248
|
+
if (!found)
|
|
249
|
+
continue;
|
|
250
|
+
multiline = multilineDelimiter(found.value);
|
|
251
|
+
if (section.length === 1 && section[0] === 'scripts' && found.key === 'run') {
|
|
252
|
+
if (stringValue(found.value) || multiline)
|
|
253
|
+
kind = 'legacy';
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (section.length !== 3 || section[0] !== 'scripts' || section[1] !== 'run')
|
|
257
|
+
continue;
|
|
258
|
+
const config = configs.get(section[2]) ?? { id: section[2] };
|
|
259
|
+
configs.set(config.id, config);
|
|
260
|
+
if (found.key === 'command' && (stringValue(found.value) || multiline))
|
|
261
|
+
config.command = true;
|
|
262
|
+
else if (found.key === 'hide' && /^(?:true|false)$/.test(found.value))
|
|
263
|
+
config.hide = found.value === 'true';
|
|
264
|
+
else if (found.key === 'available_in') {
|
|
265
|
+
let value = found.value;
|
|
266
|
+
while (arrayOpen(value) && i + 1 < lines.length)
|
|
267
|
+
value += `\n${lines[++i]}`;
|
|
268
|
+
const available = stringList(value);
|
|
269
|
+
if (available)
|
|
270
|
+
config.availableIn = available;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return { kind, configs: [...configs.values()] };
|
|
274
|
+
}
|
|
275
|
+
function resolveLayers(layers) {
|
|
276
|
+
let kind = null;
|
|
277
|
+
const resolved = new Map();
|
|
278
|
+
for (const layer of layers) {
|
|
279
|
+
if (layer.kind === 'legacy') {
|
|
280
|
+
kind = 'legacy';
|
|
281
|
+
resolved.clear();
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (layer.kind !== 'named')
|
|
285
|
+
continue;
|
|
286
|
+
if (kind === 'legacy')
|
|
287
|
+
resolved.clear();
|
|
288
|
+
kind = 'named';
|
|
289
|
+
for (const patch of layer.configs) {
|
|
290
|
+
const previous = resolved.get(patch.id) ?? { id: patch.id };
|
|
291
|
+
resolved.set(patch.id, { ...previous, ...patch });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (kind !== 'named')
|
|
295
|
+
return [];
|
|
296
|
+
return [...resolved.values()].flatMap(config => {
|
|
297
|
+
if (!config.command || config.hide || (config.availableIn && !config.availableIn.includes('local')))
|
|
298
|
+
return [];
|
|
299
|
+
return [{ id: config.id, name: displayName(config.id) }];
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
/** Resolve lower-to-higher-priority TOML layers using Conductor's per-ID merge. */
|
|
303
|
+
export function resolveRunConfigs(layers) {
|
|
304
|
+
return resolveLayers(layers.map(parseLayer));
|
|
305
|
+
}
|
|
306
|
+
function readLayer(file) {
|
|
307
|
+
try {
|
|
308
|
+
const stat = fs.statSync(file);
|
|
309
|
+
const cached = fileCache.get(file);
|
|
310
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size)
|
|
311
|
+
return cached.value;
|
|
312
|
+
const source = fs.readFileSync(file, 'utf8');
|
|
313
|
+
const value = parseLayer(source);
|
|
314
|
+
fileCache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, value });
|
|
315
|
+
return value;
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
fileCache.delete(file);
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/** Read the same user -> shared -> local -> managed settings layers Conductor resolves. */
|
|
323
|
+
export function runConfigsFor(workspace) {
|
|
324
|
+
const shared = workspace.worktree
|
|
325
|
+
? path.join(workspace.worktree, '.conductor', 'settings.toml')
|
|
326
|
+
: workspace.repo_root
|
|
327
|
+
? path.join(workspace.repo_root, '.conductor', 'settings.toml')
|
|
328
|
+
: null;
|
|
329
|
+
const files = [
|
|
330
|
+
path.join(os.homedir(), '.conductor', 'settings.toml'),
|
|
331
|
+
shared,
|
|
332
|
+
workspace.repo_root ? path.join(workspace.repo_root, '.conductor', 'settings.local.toml') : null,
|
|
333
|
+
workspace.worktree ? path.join(workspace.worktree, '.conductor', 'settings.local.toml') : null,
|
|
334
|
+
path.join(os.homedir(), '.conductor', 'settings.managed.toml')
|
|
335
|
+
];
|
|
336
|
+
return resolveLayers(files.flatMap(file => (file ? [readLayer(file)].filter((layer) => layer !== null) : [])));
|
|
337
|
+
}
|
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";
|
|
@@ -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
|
|
@@ -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,
|
package/dist-node/src/writes.js
CHANGED
|
@@ -962,27 +962,35 @@ end tell`.trim();
|
|
|
962
962
|
}
|
|
963
963
|
}
|
|
964
964
|
/**
|
|
965
|
-
* Start or stop the
|
|
965
|
+
* Start one exact workspace Run task, or stop the running one, through Conductor's toolbar.
|
|
966
966
|
*
|
|
967
967
|
* The task stays owned by Conductor — it appears in the Run panel, inherits the
|
|
968
968
|
* repository's run mode and environment, and Conductor performs its normal
|
|
969
|
-
* process-group shutdown.
|
|
970
|
-
*
|
|
969
|
+
* process-group shutdown. A named start selects that item from Conductor's own
|
|
970
|
+
* Run menu; an unnamed legacy start proceeds only when the live menu contains one
|
|
971
|
+
* task. Every path focuses and asserts the target workspace before touching it.
|
|
971
972
|
*/
|
|
972
|
-
export async function setRunTask(workspace, running) {
|
|
973
|
+
export async function setRunTask(workspace, running, runTaskName) {
|
|
973
974
|
if (!focusQuery(workspace))
|
|
974
975
|
return { ok: false, error: 'workspace has no branch to focus' };
|
|
975
976
|
const script = `
|
|
976
977
|
${CONDUCTOR_HANDLERS}
|
|
977
978
|
|
|
978
979
|
set wantRunning to (system attribute "RELAY_RUN_WANTED") is "1"
|
|
979
|
-
|
|
980
|
+
set wantedTask to do shell script "cat " & quoted form of (system attribute "RELAY_RUN_TASK_FILE")
|
|
981
|
+
return my setRunTask(wantRunning, wantedTask)`.trim();
|
|
982
|
+
const os = await import('node:os');
|
|
983
|
+
const fs = await import('node:fs/promises');
|
|
984
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'relay-run-task-'));
|
|
985
|
+
const taskFile = path.join(directory, 'name');
|
|
980
986
|
try {
|
|
987
|
+
await fs.writeFile(taskFile, runTaskName ?? '', 'utf8');
|
|
981
988
|
const { stdout } = await withTargetEnvironment({ workspace, sessionId: workspace.active_session_id }, targetEnvironment => uiTurn(() => exec('osascript', ['-e', script], {
|
|
982
989
|
env: {
|
|
983
990
|
...process.env,
|
|
984
991
|
...targetEnvironment,
|
|
985
|
-
RELAY_RUN_WANTED: running ? '1' : '0'
|
|
992
|
+
RELAY_RUN_WANTED: running ? '1' : '0',
|
|
993
|
+
RELAY_RUN_TASK_FILE: taskFile
|
|
986
994
|
},
|
|
987
995
|
timeout: SEND_ATTEMPT_MS
|
|
988
996
|
})));
|
|
@@ -998,6 +1006,9 @@ return my setRunTask(wantRunning)`.trim();
|
|
|
998
1006
|
catch (err) {
|
|
999
1007
|
return { ok: false, error: osaError(err, 'Conductor took too long to change the Run task') };
|
|
1000
1008
|
}
|
|
1009
|
+
finally {
|
|
1010
|
+
await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined);
|
|
1011
|
+
}
|
|
1001
1012
|
}
|
|
1002
1013
|
export function pickActuator(strategy) {
|
|
1003
1014
|
return strategy === 'sidecar' ? new SidecarActuator() : new AppleScriptActuator();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "conductor-remote",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.93.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"packageManager": "yarn@4.15.0",
|
|
6
6
|
"description": "Phone control panel for local Conductor agents. Reads ride SQLite + git; prompts ride Conductor's own dispatch path.",
|