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/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-DnnHEVDt.js"></script>
29
- <link rel="stylesheet" crossorigin href="/assets/index-DFclyO59.css">
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:"3b37955213b676ec4a6a2b69275ddb07"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-DnnHEVDt.js",revision:null},{url:"assets/index-DFclyO59.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\//]}))});
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
- tell application "System Events" to tell process "Conductor"
1214
- perform action "AXPress" of btn
1215
- end tell
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 into a fresh tab beside it, as a Conductor attachment, and ask the new agent your question there. Use it when a conversation has grown a second topic a running agent steered mid-turn ends up holding three threads at once, which reads badly for everyone afterwards. The copy carries the prose and the reasoning, not the tool calls, so the new agent knows what was said and decided but not every file that was read. It runs to the end of the chat unless through names an earlier message to stop at, which is how you branch from before the conversation turned. This DRIVES THE REAL UI twice (a new tab, then the send) and steals focus for a few seconds. To split the chat you are in, find its session_id with list_chats on your own workspace.',
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 tab.' },
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 a tab');
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 tab exists either way, so every path below leaves the caller somewhere to
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('the new tab is open, but the relay could not read its id back');
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 tab and the transcript are ready, but the prompt did not land (${sent.error ?? 'unknown'}) — retry it with send_prompt`);
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');
@@ -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 selected Conductor Run task. */
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'),