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
package/dist/index.html
CHANGED
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
<title>Conductor Remote</title>
|
|
26
26
|
<!-- Runs before the module bundle so it can catch a stale shell that fails to boot. -->
|
|
27
27
|
<script src="/self-heal.js"></script>
|
|
28
|
-
<script type="module" crossorigin src="/assets/index-
|
|
29
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
28
|
+
<script type="module" crossorigin src="/assets/index-xBVLsFw9.js"></script>
|
|
29
|
+
<link rel="stylesheet" crossorigin href="/assets/index-C6Qmiemg.css">
|
|
30
30
|
<link rel="manifest" href="/manifest.webmanifest"></head>
|
|
31
31
|
<body>
|
|
32
32
|
<div id="root"></div>
|
package/dist/sw.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(s[o])return;let l={};const c=e=>i(e,o),t={module:{uri:o},exports:l,require:c};s[o]=Promise.all(n.map(e=>t[e]||c(e))).then(e=>(r(...e),l))}}define(["./workbox-9c191d2f"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"e7ef44deca46c0539e6ff7bba5eb815e"},{url:"index.html",revision:"
|
|
1
|
+
if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(s[o])return;let l={};const c=e=>i(e,o),t={module:{uri:o},exports:l,require:c};s[o]=Promise.all(n.map(e=>t[e]||c(e))).then(e=>(r(...e),l))}}define(["./workbox-9c191d2f"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"e7ef44deca46c0539e6ff7bba5eb815e"},{url:"index.html",revision:"978f58c66731a6e4fb4eb27be19ef147"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-xBVLsFw9.js",revision:null},{url:"assets/index-C6Qmiemg.css",revision:null},{url:"apple-touch-icon.png",revision:"1127bb396b4648add53dce3f22c92aee"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a9b0d962686287452216492cd2247499"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
|
|
@@ -1172,6 +1172,78 @@ on runTaskButton(tg)
|
|
|
1172
1172
|
return item 1 of matches
|
|
1173
1173
|
end runTaskButton
|
|
1174
1174
|
|
|
1175
|
+
on runTaskChooser(tg)
|
|
1176
|
+
set found to {}
|
|
1177
|
+
tell application "System Events" to tell process "Conductor"
|
|
1178
|
+
repeat with entry in (get UI elements of tg whose role is "AXPopUpButton")
|
|
1179
|
+
set node to contents of entry
|
|
1180
|
+
if (my axName(node)) is "Select task" then set end of found to node
|
|
1181
|
+
end repeat
|
|
1182
|
+
end tell
|
|
1183
|
+
if (count of found) is 0 then return missing value
|
|
1184
|
+
if (count of found) > 1 then error "Conductor exposed more than one Run task chooser"
|
|
1185
|
+
return item 1 of found
|
|
1186
|
+
end runTaskChooser
|
|
1187
|
+
|
|
1188
|
+
on openRunTaskMenu(tg)
|
|
1189
|
+
-- Multiple Run configs live behind a Radix pop-up. AXPress is advertised but
|
|
1190
|
+
-- does nothing on this control; focusing it and pressing Space opens the real
|
|
1191
|
+
-- AXMenu. Choosing an item starts the task immediately — there is no second
|
|
1192
|
+
-- press on the primary Run button. "Configure" is Conductor's own trailing
|
|
1193
|
+
-- command, not a task choice.
|
|
1194
|
+
set chooser to my runTaskChooser(tg)
|
|
1195
|
+
if chooser is missing value then error "this workspace has no Run config chooser"
|
|
1196
|
+
tell application "System Events" to tell process "Conductor"
|
|
1197
|
+
set value of attribute "AXFocused" of chooser to true
|
|
1198
|
+
end tell
|
|
1199
|
+
tell application "System Events" to key code 49
|
|
1200
|
+
delay 0.4
|
|
1201
|
+
set choices to {}
|
|
1202
|
+
set wa to my webArea()
|
|
1203
|
+
tell application "System Events" to tell process "Conductor"
|
|
1204
|
+
repeat with m in (get UI elements of wa whose role is "AXMenu")
|
|
1205
|
+
repeat with entry in (get UI elements of m whose role is "AXMenuItem")
|
|
1206
|
+
set node to contents of entry
|
|
1207
|
+
set label to my axName(node)
|
|
1208
|
+
if label is not "" and label is not "Configure" then set end of choices to node
|
|
1209
|
+
end repeat
|
|
1210
|
+
end repeat
|
|
1211
|
+
end tell
|
|
1212
|
+
return choices
|
|
1213
|
+
end openRunTaskMenu
|
|
1214
|
+
|
|
1215
|
+
on startRunTaskNamed(tg, wantedTask)
|
|
1216
|
+
try
|
|
1217
|
+
set matches to {}
|
|
1218
|
+
repeat with entry in my openRunTaskMenu(tg)
|
|
1219
|
+
set node to contents of entry
|
|
1220
|
+
if (my axName(node)) is wantedTask then set end of matches to node
|
|
1221
|
+
end repeat
|
|
1222
|
+
if (count of matches) is 0 then error "no Run config named " & wantedTask
|
|
1223
|
+
if (count of matches) > 1 then error "several Run configs match " & wantedTask
|
|
1224
|
+
tell application "System Events" to tell process "Conductor"
|
|
1225
|
+
perform action "AXPress" of item 1 of matches
|
|
1226
|
+
end tell
|
|
1227
|
+
on error errText number errNum
|
|
1228
|
+
tell application "System Events" to key code 53
|
|
1229
|
+
error errText number errNum
|
|
1230
|
+
end try
|
|
1231
|
+
end startRunTaskNamed
|
|
1232
|
+
|
|
1233
|
+
on startSoleRunTask(tg)
|
|
1234
|
+
try
|
|
1235
|
+
set choices to my openRunTaskMenu(tg)
|
|
1236
|
+
if (count of choices) is 0 then error "Conductor's Run menu contains no tasks"
|
|
1237
|
+
if (count of choices) > 1 then error "choose which Run config to start"
|
|
1238
|
+
tell application "System Events" to tell process "Conductor"
|
|
1239
|
+
perform action "AXPress" of item 1 of choices
|
|
1240
|
+
end tell
|
|
1241
|
+
on error errText number errNum
|
|
1242
|
+
tell application "System Events" to key code 53
|
|
1243
|
+
error errText number errNum
|
|
1244
|
+
end try
|
|
1245
|
+
end startSoleRunTask
|
|
1246
|
+
|
|
1175
1247
|
on runTaskState(btn)
|
|
1176
1248
|
set label to my axName(btn)
|
|
1177
1249
|
if label starts with "Stop " then return "running"
|
|
@@ -1197,7 +1269,7 @@ on openRunPorts(tg)
|
|
|
1197
1269
|
return found
|
|
1198
1270
|
end openRunPorts
|
|
1199
1271
|
|
|
1200
|
-
on setRunTask(wantRunning)
|
|
1272
|
+
on setRunTask(wantRunning, wantedTask)
|
|
1201
1273
|
my activateConductor()
|
|
1202
1274
|
my focusWorkspace()
|
|
1203
1275
|
set tg to my runStrip()
|
|
@@ -1208,11 +1280,20 @@ on setRunTask(wantRunning)
|
|
|
1208
1280
|
set wantedState to "stopped"
|
|
1209
1281
|
if wantRunning then set wantedState to "running"
|
|
1210
1282
|
set changed to "false"
|
|
1283
|
+
if wantRunning and wantedTask is not "" and beforeState is "running" and taskName is not wantedTask then error taskName & " is already running; stop it before starting " & wantedTask
|
|
1211
1284
|
|
|
1212
1285
|
if beforeState is not wantedState then
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1286
|
+
if wantRunning and wantedTask is not "" then
|
|
1287
|
+
my startRunTaskNamed(tg, wantedTask)
|
|
1288
|
+
else if wantRunning and (my runTaskChooser(tg)) is not missing value then
|
|
1289
|
+
-- Legacy settings carry no name to the relay. Preserve their one-tap
|
|
1290
|
+
-- behavior only when the live menu independently confirms one choice.
|
|
1291
|
+
my startSoleRunTask(tg)
|
|
1292
|
+
else
|
|
1293
|
+
tell application "System Events" to tell process "Conductor"
|
|
1294
|
+
perform action "AXPress" of btn
|
|
1295
|
+
end tell
|
|
1296
|
+
end if
|
|
1216
1297
|
set changed to "true"
|
|
1217
1298
|
end if
|
|
1218
1299
|
|
|
@@ -1229,6 +1310,7 @@ on setRunTask(wantRunning)
|
|
|
1229
1310
|
set currentState to my runTaskState(btn)
|
|
1230
1311
|
set taskName to my runTaskName(btn)
|
|
1231
1312
|
if currentState is wantedState then
|
|
1313
|
+
if wantRunning and wantedTask is not "" and taskName is not wantedTask then error "Conductor started " & taskName & " instead of " & wantedTask
|
|
1232
1314
|
set ports to my openRunPorts(tg)
|
|
1233
1315
|
if not wantRunning or (count of ports) > 0 or attempt > 20 then exit repeat
|
|
1234
1316
|
end if
|
|
@@ -25,6 +25,7 @@ import path from 'node:path';
|
|
|
25
25
|
import { promisify } from 'node:util';
|
|
26
26
|
import { stateDir } from "./config.js";
|
|
27
27
|
import { previewUrlSettings, resolvePreviewTargets } from "./preview-urls.js";
|
|
28
|
+
import { runConfigsFor } from "./run-configs.js";
|
|
28
29
|
import { magicDnsName, tailscaleBin } from "./tailscale.js";
|
|
29
30
|
import { setRunTask } from "./writes.js";
|
|
30
31
|
const exec = promisify(execFile);
|
|
@@ -592,6 +593,7 @@ export class DevServerController {
|
|
|
592
593
|
}
|
|
593
594
|
async state(workspace) {
|
|
594
595
|
this.refreshTailscale();
|
|
596
|
+
const runConfigs = runConfigsFor(workspace);
|
|
595
597
|
const basePort = await this.portFor(workspace.id);
|
|
596
598
|
if (basePort)
|
|
597
599
|
this.ports.set(workspace.id, basePort);
|
|
@@ -653,6 +655,7 @@ export class DevServerController {
|
|
|
653
655
|
port: primary?.port ?? basePort,
|
|
654
656
|
url: firstForwarded?.url ?? null,
|
|
655
657
|
forwards,
|
|
658
|
+
runConfigs,
|
|
656
659
|
error
|
|
657
660
|
};
|
|
658
661
|
}
|
|
@@ -670,8 +673,28 @@ export class DevServerController {
|
|
|
670
673
|
.catch(() => undefined);
|
|
671
674
|
return action;
|
|
672
675
|
}
|
|
673
|
-
start(workspace) {
|
|
676
|
+
start(workspace, runConfigId) {
|
|
674
677
|
return this.exclusive(workspace, async () => {
|
|
678
|
+
const runConfigs = runConfigsFor(workspace);
|
|
679
|
+
const selected = runConfigId ? runConfigs.find(config => config.id === runConfigId) : undefined;
|
|
680
|
+
// Conductor still renders its desktop chooser for one named config. Resolve
|
|
681
|
+
// that sole choice here so the phone stays one-tap while the actuator can
|
|
682
|
+
// select the exact menu item instead of inheriting desktop selection state.
|
|
683
|
+
const taskConfig = selected ?? (runConfigs.length === 1 ? runConfigs[0] : undefined);
|
|
684
|
+
if (runConfigId && !selected) {
|
|
685
|
+
return {
|
|
686
|
+
ok: false,
|
|
687
|
+
...(await this.state(workspace)),
|
|
688
|
+
error: `Run config ${runConfigId} is not available in this workspace`
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
if (runConfigs.length > 1 && !selected) {
|
|
692
|
+
return {
|
|
693
|
+
ok: false,
|
|
694
|
+
...(await this.state(workspace)),
|
|
695
|
+
error: 'Choose which Run config to start'
|
|
696
|
+
};
|
|
697
|
+
}
|
|
675
698
|
this.refreshTailscale();
|
|
676
699
|
if (!this.bin || !this.host)
|
|
677
700
|
return { ok: false, ...(await this.state(workspace)) };
|
|
@@ -696,7 +719,7 @@ export class DevServerController {
|
|
|
696
719
|
// from the Mac, and stays inside the tap activation a phone can open a
|
|
697
720
|
// tab with. Pressing Run here would also assert a pane for a press it
|
|
698
721
|
// then decides not to make.
|
|
699
|
-
if (primaryPort && (await tcpOpen(primaryPort))) {
|
|
722
|
+
if (!selected && primaryPort && (await tcpOpen(primaryPort))) {
|
|
700
723
|
try {
|
|
701
724
|
await this.forwardAll(workspace.id, basePort, targets);
|
|
702
725
|
return { ok: true, ...(await this.state(workspace)), changed: false };
|
|
@@ -710,7 +733,7 @@ export class DevServerController {
|
|
|
710
733
|
};
|
|
711
734
|
}
|
|
712
735
|
}
|
|
713
|
-
const run = await setRunTask(workspace, true);
|
|
736
|
+
const run = await setRunTask(workspace, true, taskConfig?.name);
|
|
714
737
|
if (!run.ok)
|
|
715
738
|
return { ok: false, ...(await this.state(workspace)), error: run.error };
|
|
716
739
|
basePort ??= await workspacePort(workspace.id);
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carry one worktree's current files into a freshly-created Conductor workspace.
|
|
3
|
+
*
|
|
4
|
+
* The source index is user state, so taking a snapshot must not stage, unstage or
|
|
5
|
+
* otherwise touch it. An alternate index starts at the source index, absorbs the
|
|
6
|
+
* worktree with `git add -A`, and writes an immutable tree object. Private refs keep
|
|
7
|
+
* every captured layer alive until Conductor has created the destination worktree.
|
|
8
|
+
*
|
|
9
|
+
* Materialising is the inverse: restore the captured worktree tree, source index and
|
|
10
|
+
* source HEAD onto Conductor's freshly-created branch. That preserves committed,
|
|
11
|
+
* staged, unstaged and untracked state — the same layers Conductor's own checkpointer
|
|
12
|
+
* records for a native "fork with code".
|
|
13
|
+
*/
|
|
14
|
+
import { execFile } from 'node:child_process';
|
|
15
|
+
import crypto from 'node:crypto';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { promisify } from 'node:util';
|
|
20
|
+
const exec = promisify(execFile);
|
|
21
|
+
const GIT_TIMEOUT_MS = 60_000;
|
|
22
|
+
const STALE_REF_MS = 24 * 60 * 60 * 1000;
|
|
23
|
+
function cleanGitEnv(extra = {}) {
|
|
24
|
+
const env = { ...process.env };
|
|
25
|
+
// A relay launched from inside another Git command must still address the worktree
|
|
26
|
+
// passed to `-C`, not the repository its parent exported in these variables.
|
|
27
|
+
delete env.GIT_DIR;
|
|
28
|
+
delete env.GIT_WORK_TREE;
|
|
29
|
+
delete env.GIT_COMMON_DIR;
|
|
30
|
+
delete env.GIT_INDEX_FILE;
|
|
31
|
+
return { ...env, ...extra };
|
|
32
|
+
}
|
|
33
|
+
async function git(cwd, args, env = {}) {
|
|
34
|
+
const { stdout } = await exec('git', ['-C', cwd, ...args], {
|
|
35
|
+
encoding: 'utf8',
|
|
36
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
37
|
+
timeout: GIT_TIMEOUT_MS,
|
|
38
|
+
env: cleanGitEnv(env)
|
|
39
|
+
});
|
|
40
|
+
return stdout.trim();
|
|
41
|
+
}
|
|
42
|
+
async function commonGitDirectory(worktree) {
|
|
43
|
+
const common = await git(worktree, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
44
|
+
return fs.realpathSync(common);
|
|
45
|
+
}
|
|
46
|
+
async function assertWorktreeRoot(worktree) {
|
|
47
|
+
const root = await git(worktree, ['rev-parse', '--show-toplevel']);
|
|
48
|
+
if (fs.realpathSync(root) !== fs.realpathSync(worktree)) {
|
|
49
|
+
throw new Error(`Git resolved the workspace to an ancestor repository (${root})`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function gitOperation(worktree) {
|
|
53
|
+
for (const [name, marker] of [
|
|
54
|
+
['rebase', 'rebase-merge'],
|
|
55
|
+
['rebase', 'rebase-apply'],
|
|
56
|
+
['merge', 'MERGE_HEAD'],
|
|
57
|
+
['cherry-pick', 'CHERRY_PICK_HEAD'],
|
|
58
|
+
['revert', 'REVERT_HEAD']
|
|
59
|
+
]) {
|
|
60
|
+
const resolved = await git(worktree, ['rev-parse', '--git-path', marker]);
|
|
61
|
+
if (fs.existsSync(path.isAbsolute(resolved) ? resolved : path.join(worktree, resolved)))
|
|
62
|
+
return name;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
/** A hard-killed relay cannot run `finally`; the next capture reclaims its refs. */
|
|
67
|
+
async function pruneForkWorkspaceRefs(worktree) {
|
|
68
|
+
const refs = await git(worktree, ['for-each-ref', '--format=%(refname)', 'refs/conductor-remote/forks']);
|
|
69
|
+
for (const ref of refs.split('\n').filter(Boolean)) {
|
|
70
|
+
const match = ref.match(/^refs\/conductor-remote\/forks\/(\d+)-[^/]+\/(?:head|index|worktree)$/);
|
|
71
|
+
if (!match || Date.now() - Number(match[1]) <= STALE_REF_MS)
|
|
72
|
+
continue;
|
|
73
|
+
await git(worktree, ['update-ref', '-d', ref]).catch(() => undefined);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Snapshot the files visible to Git without changing the source's real index.
|
|
78
|
+
* Ignored build output stays behind; tracked files and ordinary untracked files go.
|
|
79
|
+
*/
|
|
80
|
+
export async function captureForkWorkspace(worktree) {
|
|
81
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'conductor-remote-fork-'));
|
|
82
|
+
const index = path.join(temp, 'index');
|
|
83
|
+
const alternateIndex = { GIT_INDEX_FILE: index };
|
|
84
|
+
try {
|
|
85
|
+
await assertWorktreeRoot(worktree);
|
|
86
|
+
const [commonDir, head, conflicts, operation] = await Promise.all([
|
|
87
|
+
commonGitDirectory(worktree),
|
|
88
|
+
git(worktree, ['rev-parse', '--verify', 'HEAD^{commit}']),
|
|
89
|
+
git(worktree, ['ls-files', '-u']),
|
|
90
|
+
gitOperation(worktree)
|
|
91
|
+
]);
|
|
92
|
+
if (!head)
|
|
93
|
+
throw new Error('the source workspace has no commit to fork');
|
|
94
|
+
if (operation)
|
|
95
|
+
throw new Error(`the source workspace is in the middle of a Git ${operation}`);
|
|
96
|
+
if (conflicts)
|
|
97
|
+
throw new Error('the source workspace has unresolved Git conflicts');
|
|
98
|
+
await pruneForkWorkspaceRefs(worktree);
|
|
99
|
+
// Seed from the real index rather than HEAD. A newly-staged file may already
|
|
100
|
+
// match .gitignore; `git add` keeps it only when the alternate index knows it
|
|
101
|
+
// is tracked, which is the same subtlety Conductor's checkpointer handles.
|
|
102
|
+
const indexTree = await git(worktree, ['write-tree']);
|
|
103
|
+
await git(worktree, ['read-tree', indexTree], alternateIndex);
|
|
104
|
+
await git(worktree, ['add', '-A', '--', '.'], alternateIndex);
|
|
105
|
+
const tree = await git(worktree, ['write-tree'], alternateIndex);
|
|
106
|
+
const ref = `refs/conductor-remote/forks/${Date.now()}-${crypto.randomBytes(6).toString('hex')}`;
|
|
107
|
+
const retained = [
|
|
108
|
+
[`${ref}/head`, head],
|
|
109
|
+
[`${ref}/index`, indexTree],
|
|
110
|
+
[`${ref}/worktree`, tree]
|
|
111
|
+
];
|
|
112
|
+
try {
|
|
113
|
+
for (const [name, object] of retained)
|
|
114
|
+
await git(worktree, ['update-ref', name, object]);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
for (const [name, object] of retained) {
|
|
118
|
+
await git(worktree, ['update-ref', '-d', name, object]).catch(() => undefined);
|
|
119
|
+
}
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
return { tree, head, indexTree, ref, commonDir };
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Replace a brand-new destination with the captured Git layers. The destination keeps
|
|
130
|
+
* its new branch name, but that branch moves to the source commit and receives the
|
|
131
|
+
* source's staged/unstaged state. Refuse any prior destination change: even during
|
|
132
|
+
* setup, silently erasing work is the wrong race.
|
|
133
|
+
*/
|
|
134
|
+
export async function materializeForkWorkspace(snapshot, worktree) {
|
|
135
|
+
await assertWorktreeRoot(worktree);
|
|
136
|
+
const commonDir = await commonGitDirectory(worktree);
|
|
137
|
+
if (commonDir !== snapshot.commonDir) {
|
|
138
|
+
throw new Error('the new workspace belongs to a different Git repository');
|
|
139
|
+
}
|
|
140
|
+
const dirty = await git(worktree, ['status', '--porcelain=v1']);
|
|
141
|
+
if (dirty)
|
|
142
|
+
throw new Error('the new workspace changed files before the fork snapshot could be installed');
|
|
143
|
+
const destinationHead = await git(worktree, ['rev-parse', '--verify', 'HEAD^{commit}']);
|
|
144
|
+
await git(worktree, ['read-tree', '--reset', '-u', snapshot.tree]);
|
|
145
|
+
const installed = await git(worktree, ['write-tree']);
|
|
146
|
+
if (installed !== snapshot.tree)
|
|
147
|
+
throw new Error('Git did not install the complete fork snapshot');
|
|
148
|
+
// Restore the source index without touching those checked-out bytes, then move
|
|
149
|
+
// only the destination branch ref. The branch name remains Conductor's own.
|
|
150
|
+
await git(worktree, ['read-tree', '--reset', snapshot.indexTree]);
|
|
151
|
+
const staged = await git(worktree, ['write-tree']);
|
|
152
|
+
if (staged !== snapshot.indexTree)
|
|
153
|
+
throw new Error('Git did not restore the fork’s staged state');
|
|
154
|
+
await git(worktree, ['update-ref', 'HEAD', snapshot.head, destinationHead]);
|
|
155
|
+
await git(worktree, ['update-index', '-q', '--refresh']).catch(() => undefined);
|
|
156
|
+
}
|
|
157
|
+
/** Drop the private reachability refs once the destination owns the files. */
|
|
158
|
+
export async function releaseForkWorkspace(snapshot) {
|
|
159
|
+
const errors = [];
|
|
160
|
+
for (const [suffix, object] of [
|
|
161
|
+
['head', snapshot.head],
|
|
162
|
+
['index', snapshot.indexTree],
|
|
163
|
+
['worktree', snapshot.tree]
|
|
164
|
+
]) {
|
|
165
|
+
try {
|
|
166
|
+
await git(snapshot.commonDir, [
|
|
167
|
+
'--git-dir',
|
|
168
|
+
snapshot.commonDir,
|
|
169
|
+
'update-ref',
|
|
170
|
+
'-d',
|
|
171
|
+
`${snapshot.ref}/${suffix}`,
|
|
172
|
+
object
|
|
173
|
+
]);
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
errors.push(err);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (errors.length)
|
|
180
|
+
throw errors[0];
|
|
181
|
+
}
|
|
@@ -472,12 +472,12 @@ export function createTools(call) {
|
|
|
472
472
|
},
|
|
473
473
|
{
|
|
474
474
|
name: 'split_chat',
|
|
475
|
-
description: 'Move a tangent out of a chat: copy that chat
|
|
475
|
+
description: 'Move a tangent out of a chat: copy that chat as a Conductor attachment and ask the new agent your question there. By default it opens a fresh tab beside the source (same files); new_workspace creates a separate Conductor workspace and carries the source worktree’s current tracked and untracked-not-ignored files into it. The transcript copy carries prose and reasoning, not tool calls, and runs to the end unless through names an earlier message. A tab fork DRIVES THE REAL UI twice (new tab, then send); a workspace fork creates through a deep link, then drives the UI for the send. Both steal focus. To split the chat you are in, find its session_id with list_chats on your own workspace.',
|
|
476
476
|
inputSchema: {
|
|
477
477
|
type: 'object',
|
|
478
478
|
properties: {
|
|
479
479
|
session_id: { type: 'string', description: 'The chat to copy (list_chats / search_chats).' },
|
|
480
|
-
prompt: { type: 'string', description: 'What to ask in the new
|
|
480
|
+
prompt: { type: 'string', description: 'What to ask in the new destination.' },
|
|
481
481
|
workspace_id: { type: 'string', description: 'Its workspace. Resolved from the chat when omitted.' },
|
|
482
482
|
include_thinking: {
|
|
483
483
|
type: 'boolean',
|
|
@@ -487,6 +487,10 @@ export function createTools(call) {
|
|
|
487
487
|
type: 'boolean',
|
|
488
488
|
description: 'Carry tool calls across as one line each (default false — mostly noise, and most of the bytes).'
|
|
489
489
|
},
|
|
490
|
+
new_workspace: {
|
|
491
|
+
type: 'boolean',
|
|
492
|
+
description: 'Create a separate workspace carrying the source’s current code (default false: a new tab with the same files).'
|
|
493
|
+
},
|
|
490
494
|
through: {
|
|
491
495
|
type: 'string',
|
|
492
496
|
description: 'Stop the copy at this message, it included — a cursor from read_chat or search_chats. Default: the whole chat.'
|
|
@@ -498,6 +502,7 @@ export function createTools(call) {
|
|
|
498
502
|
const sessionId = need(args, 'session_id');
|
|
499
503
|
const prompt = need(args, 'prompt');
|
|
500
504
|
const through = str(args.through);
|
|
505
|
+
const destination = args.new_workspace === true ? 'workspace' : 'chat';
|
|
501
506
|
const throughRowid = through ? parseChatCursor(through) : null;
|
|
502
507
|
if (through && throughRowid === null) {
|
|
503
508
|
throw new Error('through must be a cursor returned by read_chat or search_chats');
|
|
@@ -509,12 +514,13 @@ export function createTools(call) {
|
|
|
509
514
|
workspaceId: str(args.workspace_id),
|
|
510
515
|
includeThinking: args.include_thinking !== false,
|
|
511
516
|
includeTools: args.include_tools === true,
|
|
512
|
-
throughRowid: throughRowid ?? undefined
|
|
517
|
+
throughRowid: throughRowid ?? undefined,
|
|
518
|
+
destination
|
|
513
519
|
},
|
|
514
520
|
timeoutMs: WRITE_TIMEOUT_MS
|
|
515
521
|
});
|
|
516
522
|
if (!split.ok)
|
|
517
|
-
throw new Error(split.error ?? 'the split did not open
|
|
523
|
+
throw new Error(split.error ?? 'the split did not open its destination');
|
|
518
524
|
const { attachment: file } = split;
|
|
519
525
|
// Name the cut. A transcript that quietly dropped half the chat reads exactly
|
|
520
526
|
// like a complete one to whoever gets it next.
|
|
@@ -525,12 +531,13 @@ export function createTools(call) {
|
|
|
525
531
|
file.elided.later ? plural(file.elided.later, 'entry after the cut', 'entries after the cut') : ''
|
|
526
532
|
].filter(Boolean);
|
|
527
533
|
const lines = [
|
|
528
|
-
`copied ${plural(file.kept, 'entry', 'entries')} (${Math.round(file.bytes / 1024)}kB) to ${file.path}${cut.length ? `, without ${cut.join(' or ')}` : ''}
|
|
534
|
+
`copied ${plural(file.kept, 'entry', 'entries')} (${Math.round(file.bytes / 1024)}kB) to ${file.path}${cut.length ? `, without ${cut.join(' or ')}` : ''}`,
|
|
535
|
+
...(destination === 'workspace' ? [`workspace_id: ${split.workspaceId} (current code carried across)`] : [])
|
|
529
536
|
];
|
|
530
|
-
// The
|
|
537
|
+
// The destination exists either way, so every path below leaves the caller somewhere to
|
|
531
538
|
// go rather than reporting a bare failure over work that half-happened.
|
|
532
539
|
if (!split.sessionId) {
|
|
533
|
-
lines.push(
|
|
540
|
+
lines.push(`the new ${destination} is open, but the relay could not read its chat id back`);
|
|
534
541
|
lines.push('find it with list_chats, then send_prompt the question yourself');
|
|
535
542
|
return lines.join('\n');
|
|
536
543
|
}
|
|
@@ -543,7 +550,7 @@ export function createTools(call) {
|
|
|
543
550
|
if (sent.parked)
|
|
544
551
|
lines.push('the Mac is locked — the prompt is parked and lands on unlock');
|
|
545
552
|
else if (!sent.ok)
|
|
546
|
-
lines.push(`! the
|
|
553
|
+
lines.push(`! the ${destination} and transcript are ready, but the prompt did not land (${sent.error ?? 'unknown'}) — retry it with send_prompt`);
|
|
547
554
|
else
|
|
548
555
|
lines.push(sent.warning ? `sent (${sent.warning})` : 'sent');
|
|
549
556
|
return lines.join('\n');
|
package/dist-node/src/routes.js
CHANGED
|
@@ -101,7 +101,7 @@ export const routes = {
|
|
|
101
101
|
workspaceStatus: param('POST', '/api/workspaces/:workspaceId/status'),
|
|
102
102
|
/** Put the workspace away (Conductor's ⌘⇧A) — the one write that deletes a worktree. */
|
|
103
103
|
archiveWorkspace: param('POST', '/api/workspaces/:workspaceId/archive'),
|
|
104
|
-
/** Read, start/forward, or stop a local workspace's
|
|
104
|
+
/** Read configs, start/forward one, or stop a local workspace's Conductor Run task. */
|
|
105
105
|
devServer: param('GET', '/api/workspaces/:workspaceId/dev-server'),
|
|
106
106
|
startDevServer: param('POST', '/api/workspaces/:workspaceId/dev-server'),
|
|
107
107
|
stopDevServer: param('DELETE', '/api/workspaces/:workspaceId/dev-server'),
|