conductor-remote 1.42.2 → 1.43.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
@@ -13,8 +13,8 @@
13
13
  <title>Conductor Remote</title>
14
14
  <!-- Runs before the module bundle so it can catch a stale shell that fails to boot. -->
15
15
  <script src="/self-heal.js"></script>
16
- <script type="module" crossorigin src="/assets/index-DFdIcbun.js"></script>
17
- <link rel="stylesheet" crossorigin href="/assets/index-95IdnA_r.css">
16
+ <script type="module" crossorigin src="/assets/index-CZzXID1o.js"></script>
17
+ <link rel="stylesheet" crossorigin href="/assets/index-4dAWTDxH.css">
18
18
  <link rel="manifest" href="/manifest.webmanifest"></head>
19
19
  <body>
20
20
  <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:"e1e682e2e5e88fa03b7808ae9db8e098"},{url:"index.html",revision:"94f9b873fb04ccb55680c1b74315be0f"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-DFdIcbun.js",revision:null},{url:"assets/index-95IdnA_r.css",revision:null},{url:"apple-touch-icon.png",revision:"2b9301416b880d45d4bb655f2600d1f2"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
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 t=e=>i(e,o),c={module:{uri:o},exports:l,require:t};s[o]=Promise.all(n.map(e=>c[e]||t(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:"e1e682e2e5e88fa03b7808ae9db8e098"},{url:"index.html",revision:"fb567ae18a4d22dde0d378f6175bf2d3"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-CZzXID1o.js",revision:null},{url:"assets/index-4dAWTDxH.css",revision:null},{url:"apple-touch-icon.png",revision:"2b9301416b880d45d4bb655f2600d1f2"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Conductor attachments, written from outside Conductor.
3
+ *
4
+ * An attachment is two things and, as of Conductor 0.76, only two things:
5
+ *
6
+ * 1. a file at `<worktree>/.context/attachments/<6 chars>/<name>`, and
7
+ * 2. a token in the prompt text: `@⟦<name>⟧(<the path, percent-encoded>)`.
8
+ *
9
+ * There is no third thing, and that is what makes this safe to do here. `conductor.db`
10
+ * *has* an `attachments` table, and it is dead in the same way `workspaces.unread` is:
11
+ * 471 rows, last written 2026-05-19, none of them in the `<id>/<name>` layout every
12
+ * attachment has used since. Verified against attachments made the same day this was
13
+ * written — zero rows. So the relay produces a real attachment while keeping its DB
14
+ * handle read-only, which is not a nicety here but the rule the whole design rests on.
15
+ *
16
+ * The one thing that stays unproven until a send runs: whether Conductor re-parses a
17
+ * token *written into the composer* back into a chip, or leaves it as text. Callers
18
+ * must not depend on the answer. Name the file in plain words next to the token as
19
+ * well, and the next agent reads it either way.
20
+ */
21
+ import crypto from 'node:crypto';
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ /** Where Conductor keeps them, relative to the worktree. */
25
+ export const ATTACHMENT_DIR = path.join('.context', 'attachments');
26
+ /**
27
+ * Conductor's own ids are six mixed-case alphanumerics (`jOTeCX`, `Vyc35T`, `7qygLU`),
28
+ * so this matches the shape rather than inventing one. Modulo bias is irrelevant: the
29
+ * id names a directory, it guards nothing, and the caller retries on collision anyway.
30
+ */
31
+ const ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
32
+ function attachmentId() {
33
+ return Array.from(crypto.randomBytes(6), b => ID_ALPHABET[b % ID_ALPHABET.length]).join('');
34
+ }
35
+ /**
36
+ * A file name that cannot leave the directory it was meant for.
37
+ *
38
+ * The name here is built from a chat title, which is free text written by a model, so
39
+ * it can hold a slash as easily as a space. `path.join` would take one and quietly
40
+ * write somewhere else in the worktree.
41
+ */
42
+ export function attachmentName(name) {
43
+ const flat = name
44
+ .replace(/[/\\]/g, '-')
45
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are exactly what must not reach a path
46
+ .replace(/[\u0000-\u001f\u007f]/g, '')
47
+ // A leading dot hides the file, and two of them climb out of the directory.
48
+ .replace(/^\.+/, '')
49
+ .trim();
50
+ // 255 bytes is the filesystem's limit and a title can be long; leave room for `.md`.
51
+ return (flat.length > 120 ? flat.slice(0, 120).trim() : flat) || 'attachment';
52
+ }
53
+ /** The composer's own syntax for an attached file. `encodeURIComponent` matches it exactly, `/` included. */
54
+ export function attachmentToken(name, relPath) {
55
+ return `@⟦${name}⟧(${encodeURIComponent(relPath)})`;
56
+ }
57
+ /**
58
+ * Write one, and return everything a caller needs to reference it.
59
+ *
60
+ * Each attachment gets its own directory, exactly as Conductor does it, so two files
61
+ * with the same name never collide and the id stays out of the visible name.
62
+ */
63
+ export function writeAttachment(worktree, name, body) {
64
+ const safe = attachmentName(name);
65
+ let id = attachmentId();
66
+ let dir = path.join(worktree, ATTACHMENT_DIR, id);
67
+ // Six characters from 62 makes this vanishingly unlikely; retrying is still cheaper
68
+ // than overwriting an attachment somebody else is about to send.
69
+ for (let i = 0; i < 5 && fs.existsSync(dir); i++) {
70
+ id = attachmentId();
71
+ dir = path.join(worktree, ATTACHMENT_DIR, id);
72
+ }
73
+ fs.mkdirSync(dir, { recursive: true });
74
+ const absPath = path.join(dir, safe);
75
+ fs.writeFileSync(absPath, body, 'utf8');
76
+ const relPath = path.join(ATTACHMENT_DIR, id, safe);
77
+ return { name: safe, relPath, absPath, bytes: Buffer.byteLength(body), token: attachmentToken(safe, relPath) };
78
+ }
@@ -1,10 +1,17 @@
1
1
  /**
2
- * The ten MCP tools, and the JSON-RPC dispatcher that serves them.
2
+ * The MCP tools, and the JSON-RPC dispatcher that serves them.
3
3
  *
4
4
  * Shared by both transports, which is the only reason they can't drift: `src/mcp.ts`
5
5
  * runs this over stdio as a separate process, and `src/server.ts` mounts the same
6
6
  * dispatcher at `POST /mcp` for clients that can only reach a URL.
7
7
  *
8
+ * The tool set tracks `/api`, minus the routes that only exist to back a button on the
9
+ * phone. An agent needs no `merge` (it holds `gh`, and `send_prompt` can ask the agent
10
+ * that owns the branch), no push subscription, and no settings editor. What it does need
11
+ * is everything it cannot reach any other way: the model/effort/plan/fast controls, which
12
+ * live in Conductor's UI and nowhere else, the prompts the relay is holding on its behalf,
13
+ * the sleep window that keeps this Mac reachable at all, and the relay's own log.
14
+ *
8
15
  * Every tool is an HTTP call to the relay, injected as `call`, and that is the
9
16
  * load-bearing decision rather than a convenience. Conductor has one shared window,
10
17
  * so the only thing that makes writes safe is `writes.ts` ▸ `uiTurn`, and that lock is
@@ -18,11 +25,12 @@
18
25
  * router it would otherwise have to be carved out of, and it keeps *one* code path:
19
26
  * a tool cannot behave differently over HTTP than it does over stdio.
20
27
  */
28
+ import { routes } from "./routes.js";
21
29
  import { HIT_CLOSE, HIT_OPEN, workspaceTitle } from "./shared.js";
22
30
  /** Versions we know how to speak. The client's choice wins when we know it. */
23
31
  export const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
24
32
  export const SERVER_INFO = { name: 'conductor-remote', version: '1' };
25
- export const INSTRUCTIONS = 'Drives local Conductor agents through the conductor-remote relay. search_chats and read_chat reach archived workspaces, which is where most finished work lives. create_workspace touches no UI. send_prompt, stop_turn and set_workspace_status drive the real Mac UI and steal focus for a few seconds — confirm with the user before using them on a chat they did not name.';
33
+ export const INSTRUCTIONS = 'Drives local Conductor agents through the conductor-remote relay. search_chats and read_chat reach archived workspaces, which is where most finished work lives. create_workspace, dismiss_prompt, keep_awake and relay_logs touch no UI. send_prompt, split_chat, stop_turn, set_agent_options, list_models and set_workspace_status drive the real Mac UI and steal focus for a few seconds — confirm with the user before using them on a chat they did not name.';
26
34
  /** Reads are quick; a UI write is measured in tens of seconds (writes.ts ▸ SEND_ATTEMPT_MS). */
27
35
  export const READ_TIMEOUT_MS = 10_000;
28
36
  export const WRITE_TIMEOUT_MS = 75_000;
@@ -36,6 +44,24 @@ function unmark(text) {
36
44
  function clip(text, max) {
37
45
  return text.length > max ? `${text.slice(0, max)}… [${text.length - max} more chars]` : text;
38
46
  }
47
+ function plural(n, one, many = `${one}s`) {
48
+ return `${n} ${n === 1 ? one : many}`;
49
+ }
50
+ /** Wall-clock stamp for a log line. Null on continuation lines the file parser couldn't date. */
51
+ function stamp(t) {
52
+ if (t === null)
53
+ return ' ';
54
+ const d = new Date(t);
55
+ const p = (n) => String(n).padStart(2, '0');
56
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
57
+ }
58
+ /** "in 42m" / "38m ago" — an absolute epoch is unreadable, and an agent's clock may differ. */
59
+ function relative(at) {
60
+ const mins = Math.round((at - Date.now()) / 60_000);
61
+ if (mins > 0)
62
+ return `in ${mins}m`;
63
+ return mins < 0 ? `${-mins}m ago` : 'now';
64
+ }
39
65
  const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
40
66
  const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
41
67
  function need(args, key) {
@@ -61,7 +87,7 @@ export function createTools(call) {
61
87
  run: async (args) => {
62
88
  const query = need(args, 'query');
63
89
  const limit = num(args.limit);
64
- const data = await call(`/api/search?q=${encodeURIComponent(query)}${limit ? `&limit=${limit}` : ''}`);
90
+ const data = await call(`${routes.search.path()}?q=${encodeURIComponent(query)}${limit ? `&limit=${limit}` : ''}`);
65
91
  const lines = [];
66
92
  if (data.index.error)
67
93
  lines.push(`! chat index unavailable (${data.index.error}) — names matched only`);
@@ -92,20 +118,26 @@ export function createTools(call) {
92
118
  properties: {
93
119
  session_id: { type: 'string', description: 'From search_chats or list_chats.' },
94
120
  limit: { type: 'number', description: 'How many trailing entries to return (default 40, max 400).' },
95
- include_tools: { type: 'boolean', description: 'Include tool-call and thinking rows (default false).' }
121
+ include_thinking: { type: 'boolean', description: 'Include the agent’s reasoning (default false).' },
122
+ include_tools: {
123
+ type: 'boolean',
124
+ description: 'Include tool calls and failed tool output (default false).'
125
+ }
96
126
  },
97
127
  required: ['session_id']
98
128
  },
99
129
  run: async (args) => {
100
130
  const sessionId = need(args, 'session_id');
101
131
  const limit = Math.min(400, Math.max(1, num(args.limit) ?? 40));
132
+ // One flag each, because they answer different questions: tools are what the agent
133
+ // *did*, thinking is why it did it. Reading both off `include_tools` meant the only
134
+ // way to see the reasoning was to take the tool churn along with it.
102
135
  const includeTools = args.include_tools === true;
103
- const data = await call(`/api/sessions/${encodeURIComponent(sessionId)}/messages?after=0`, {
136
+ const includeThinking = args.include_thinking === true;
137
+ const data = await call(`${routes.messages.path(sessionId)}?after=0`, {
104
138
  timeoutMs: 30_000
105
139
  });
106
- const wanted = includeTools
107
- ? data.entries
108
- : data.entries.filter(e => e.role === 'user' || e.role === 'assistant');
140
+ const wanted = data.entries.filter(e => e.role === 'thinking' ? includeThinking : e.role === 'tool' ? includeTools : true);
109
141
  if (!wanted.length)
110
142
  return `no messages in session ${sessionId}`;
111
143
  const tail = wanted.slice(-limit);
@@ -122,7 +154,7 @@ export function createTools(call) {
122
154
  },
123
155
  {
124
156
  name: 'list_workspaces',
125
- description: 'List the live (non-archived) Conductor workspaces with what each one is doing right now: agent status, model, branch, PR state. Use this to see what is running before starting or steering anything.',
157
+ description: 'List the live (non-archived) Conductor workspaces with what each one is doing right now: agent status, model, branch, PR state, and any prompt the relay is still holding for it. Use this to see what is running before starting or steering anything.',
126
158
  inputSchema: {
127
159
  type: 'object',
128
160
  properties: {
@@ -134,16 +166,29 @@ export function createTools(call) {
134
166
  },
135
167
  run: async (args) => {
136
168
  const status = str(args.status);
137
- const data = await call('/api/state');
169
+ const data = await call(routes.state.path());
138
170
  const shown = status ? data.workspaces.filter(w => w.session_status === status) : data.workspaces;
139
171
  if (!shown.length)
140
172
  return status ? `no workspace is ${status}` : 'no live workspaces';
141
173
  return shown
142
- .map(w => [
143
- `${w.session_status === 'working' ? '▶' : '·'} ${workspaceTitle(w)}`,
144
- ` ${[w.repo_name, w.branch, w.model, w.pr_number ? `PR #${w.pr_number} ${w.pr_status ?? ''}`.trim() : null].filter(Boolean).join(' · ')}`,
145
- ` workspace_id: ${w.id}${w.active_session_id ? ` session_id: ${w.active_session_id}` : ''}`
146
- ].join('\n'))
174
+ .map(w => {
175
+ const lines = [
176
+ `${w.session_status === 'working' ? '' : '·'} ${workspaceTitle(w)}`,
177
+ ` ${[w.repo_name, w.branch, w.model, w.pr_number ? `PR #${w.pr_number} ${w.pr_status ?? ''}`.trim() : null].filter(Boolean).join(' · ')}`,
178
+ ` workspace_id: ${w.id}${w.active_session_id ? ` session_id: ${w.active_session_id}` : ''}`
179
+ ];
180
+ // An undelivered prompt is invisible from the DB — it lives in the relay's own
181
+ // queues — so an agent reading only status would call a stalled workspace idle
182
+ // and send a second copy of the prompt already waiting on it (dismiss_prompt).
183
+ if (w.pending_prompt) {
184
+ const p = w.pending_prompt;
185
+ lines.push(` ! first prompt ${p.status}: ${clip(unmark(p.text), 120)}${p.error ? ` — ${p.error}` : ''}`);
186
+ }
187
+ for (const p of w.parked_prompts ?? []) {
188
+ lines.push(` ! prompt ${p.status} for session ${p.sessionId} (${p.reason}): ${clip(unmark(p.text), 120)}`);
189
+ }
190
+ return lines.join('\n');
191
+ })
147
192
  .join('\n');
148
193
  }
149
194
  },
@@ -157,7 +202,7 @@ export function createTools(call) {
157
202
  },
158
203
  run: async (args) => {
159
204
  const id = need(args, 'workspace_id');
160
- const data = await call(`/api/workspaces/${encodeURIComponent(id)}/sessions`);
205
+ const data = await call(routes.sessions.path(id));
161
206
  if (!data.sessions.length)
162
207
  return `no chats in workspace ${id}`;
163
208
  return data.sessions
@@ -175,7 +220,7 @@ export function createTools(call) {
175
220
  },
176
221
  run: async (args) => {
177
222
  const id = need(args, 'workspace_id');
178
- const data = await call(`/api/workspaces/${encodeURIComponent(id)}/diff`, {
223
+ const data = await call(routes.diff.path(id), {
179
224
  timeoutMs: 30_000
180
225
  });
181
226
  if (!data.files.length)
@@ -188,7 +233,7 @@ export function createTools(call) {
188
233
  description: 'The repos Conductor can create a workspace in. Use before create_workspace to get an exact repo name.',
189
234
  inputSchema: { type: 'object', properties: {} },
190
235
  run: async () => {
191
- const data = await call('/api/repos');
236
+ const data = await call(routes.repos.path());
192
237
  return data.repos.map(r => `${r.name} (${r.default_branch ?? '?'}) ${r.root_path ?? ''}`).join('\n');
193
238
  }
194
239
  },
@@ -211,8 +256,8 @@ export function createTools(call) {
211
256
  const repo = need(args, 'repo');
212
257
  const prompt = str(args.prompt);
213
258
  const send = args.wait_for_send === true;
214
- const data = await call('/api/workspaces', {
215
- method: 'POST',
259
+ const data = await call(routes.createWorkspace.path(), {
260
+ method: routes.createWorkspace.method,
216
261
  body: { repo, prompt, send },
217
262
  timeoutMs: send ? WRITE_TIMEOUT_MS : 30_000
218
263
  });
@@ -247,8 +292,8 @@ export function createTools(call) {
247
292
  run: async (args) => {
248
293
  const sessionId = need(args, 'session_id');
249
294
  const text = need(args, 'text');
250
- const data = await call(`/api/sessions/${encodeURIComponent(sessionId)}/prompt`, {
251
- method: 'POST',
295
+ const data = await call(routes.sendPrompt.path(sessionId), {
296
+ method: routes.sendPrompt.method,
252
297
  body: { text, workspaceId: str(args.workspace_id) },
253
298
  timeoutMs: WRITE_TIMEOUT_MS
254
299
  });
@@ -259,6 +304,73 @@ export function createTools(call) {
259
304
  return data.warning ? `sent (${data.warning})` : 'sent';
260
305
  }
261
306
  },
307
+ {
308
+ name: 'split_chat',
309
+ 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. 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.',
310
+ inputSchema: {
311
+ type: 'object',
312
+ properties: {
313
+ session_id: { type: 'string', description: 'The chat to copy (list_chats / search_chats).' },
314
+ prompt: { type: 'string', description: 'What to ask in the new tab.' },
315
+ workspace_id: { type: 'string', description: 'Its workspace. Resolved from the chat when omitted.' },
316
+ include_thinking: {
317
+ type: 'boolean',
318
+ description: 'Carry the agent’s reasoning across (default true — it is usually the useful half).'
319
+ },
320
+ include_tools: {
321
+ type: 'boolean',
322
+ description: 'Carry tool calls across as one line each (default false — mostly noise, and most of the bytes).'
323
+ }
324
+ },
325
+ required: ['session_id', 'prompt']
326
+ },
327
+ run: async (args) => {
328
+ const sessionId = need(args, 'session_id');
329
+ const prompt = need(args, 'prompt');
330
+ const split = await call(routes.splitChat.path(sessionId), {
331
+ method: routes.splitChat.method,
332
+ body: {
333
+ prompt,
334
+ workspaceId: str(args.workspace_id),
335
+ includeThinking: args.include_thinking !== false,
336
+ includeTools: args.include_tools === true
337
+ },
338
+ timeoutMs: WRITE_TIMEOUT_MS
339
+ });
340
+ if (!split.ok)
341
+ throw new Error(split.error ?? 'the split did not open a tab');
342
+ const { attachment: file } = split;
343
+ // Name the cut. A transcript that quietly dropped half the chat reads exactly
344
+ // like a complete one to whoever gets it next.
345
+ const cut = [
346
+ file.elided.tools ? plural(file.elided.tools, 'tool call') : '',
347
+ file.elided.thinking ? plural(file.elided.thinking, 'thinking block') : ''
348
+ ].filter(Boolean);
349
+ const lines = [
350
+ `copied ${plural(file.kept, 'entry', 'entries')} (${Math.round(file.bytes / 1024)}kB) to ${file.path}${cut.length ? `, without ${cut.join(' or ')}` : ''}`
351
+ ];
352
+ // The tab exists either way, so every path below leaves the caller somewhere to
353
+ // go rather than reporting a bare failure over work that half-happened.
354
+ if (!split.sessionId) {
355
+ lines.push('the new tab is open, but the relay could not read its id back');
356
+ lines.push('find it with list_chats, then send_prompt the question yourself');
357
+ return lines.join('\n');
358
+ }
359
+ lines.push(`session_id: ${split.sessionId}`);
360
+ const sent = await call(routes.sendPrompt.path(split.sessionId), {
361
+ method: routes.sendPrompt.method,
362
+ body: { text: split.text, workspaceId: split.workspaceId },
363
+ timeoutMs: WRITE_TIMEOUT_MS
364
+ });
365
+ if (sent.parked)
366
+ lines.push('the Mac is locked — the prompt is parked and lands on unlock');
367
+ else if (!sent.ok)
368
+ lines.push(`! the tab and the transcript are ready, but the prompt did not land (${sent.error ?? 'unknown'}) — retry it with send_prompt`);
369
+ else
370
+ lines.push(sent.warning ? `sent (${sent.warning})` : 'sent');
371
+ return lines.join('\n');
372
+ }
373
+ },
262
374
  {
263
375
  name: 'stop_turn',
264
376
  description: 'Cancel the answer a chat is currently streaming — Conductor’s own "Cancel agent". Drives the real UI. A chat that already finished answers alreadyIdle, which is a success. This destroys the in-flight work of another agent, so ask the user first.',
@@ -275,8 +387,8 @@ export function createTools(call) {
275
387
  },
276
388
  run: async (args) => {
277
389
  const sessionId = need(args, 'session_id');
278
- const data = await call(`/api/sessions/${encodeURIComponent(sessionId)}/stop`, {
279
- method: 'POST',
390
+ const data = await call(routes.stop.path(sessionId), {
391
+ method: routes.stop.method,
280
392
  body: { workspaceId: str(args.workspace_id) },
281
393
  timeoutMs: WRITE_TIMEOUT_MS
282
394
  });
@@ -287,6 +399,75 @@ export function createTools(call) {
287
399
  return 'stopped';
288
400
  }
289
401
  },
402
+ {
403
+ name: 'list_models',
404
+ description: 'The models this chat can be switched to, read live off Conductor’s own picker rather than a hard-coded list that would rot. This DRIVES THE REAL UI — it focuses the workspace and opens the menu — so it costs a few seconds of stolen focus, same as a send. Call it before set_agent_options when you do not already know the exact label.',
405
+ inputSchema: {
406
+ type: 'object',
407
+ properties: {
408
+ session_id: { type: 'string' },
409
+ workspace_id: { type: 'string', description: 'Required: the relay locates the chat inside it.' }
410
+ },
411
+ required: ['session_id', 'workspace_id']
412
+ },
413
+ run: async (args) => {
414
+ const sessionId = need(args, 'session_id');
415
+ const workspaceId = need(args, 'workspace_id');
416
+ const data = await call(`${routes.models.path(sessionId)}?workspaceId=${encodeURIComponent(workspaceId)}`, { timeoutMs: WRITE_TIMEOUT_MS });
417
+ if (!data.ok)
418
+ throw new Error(data.error ?? 'could not read the model menu');
419
+ return data.models?.length ? data.models.join('\n') : 'the menu listed no models';
420
+ }
421
+ },
422
+ {
423
+ name: 'set_agent_options',
424
+ description: 'Change how a chat’s agent runs: model, reasoning effort, plan mode, fast mode. Conductor keeps these in its composer and nowhere else, so this is the only way to reach them — a prompt cannot. DRIVES THE REAL UI and steals focus for a few seconds. The change is confirmed against Conductor’s database before this answers. Applies to the NEXT turn, so set it before send_prompt, not during one. Ask the user before re-pointing a chat they did not name at a different model.',
425
+ inputSchema: {
426
+ type: 'object',
427
+ properties: {
428
+ session_id: { type: 'string' },
429
+ workspace_id: {
430
+ type: 'string',
431
+ description: 'Strongly recommended: it is what the relay asserts against before pressing anything.'
432
+ },
433
+ model: { type: 'string', description: 'A label from list_models. An unambiguous prefix is enough.' },
434
+ effort: { type: 'string', enum: ['low', 'medium', 'high', 'xhigh', 'max', 'ultracode'] },
435
+ plan: { type: 'boolean', description: 'Conductor’s Plan checkbox.' },
436
+ fast: {
437
+ type: 'boolean',
438
+ description: 'Fast mode. Only some models offer it; a missing button is reported, not ignored.'
439
+ }
440
+ },
441
+ required: ['session_id']
442
+ },
443
+ run: async (args) => {
444
+ const sessionId = need(args, 'session_id');
445
+ const patch = {
446
+ model: str(args.model),
447
+ effort: str(args.effort),
448
+ plan: typeof args.plan === 'boolean' ? args.plan : undefined,
449
+ fast: typeof args.fast === 'boolean' ? args.fast : undefined,
450
+ workspaceId: str(args.workspace_id)
451
+ };
452
+ if (patch.model === undefined &&
453
+ patch.effort === undefined &&
454
+ patch.plan === undefined &&
455
+ patch.fast === undefined)
456
+ throw new Error('nothing to change — pass at least one of model, effort, plan, fast');
457
+ const data = await call(routes.agent.path(sessionId), {
458
+ method: routes.agent.method,
459
+ body: patch,
460
+ timeoutMs: WRITE_TIMEOUT_MS
461
+ });
462
+ if (!data.ok)
463
+ throw new Error(data.error ?? 'the change did not land');
464
+ // The re-read row is the receipt, so report *it* rather than what was asked for.
465
+ const s = data.session;
466
+ return s
467
+ ? `now: ${[s.model, s.claude_effort_level, s.permission_mode, s.fast_mode ? 'fast' : null].filter(Boolean).join(' · ')}`
468
+ : 'applied';
469
+ }
470
+ },
290
471
  {
291
472
  name: 'set_workspace_status',
292
473
  description: 'Set a workspace’s status in Conductor’s sidebar (backlog, in-progress, in-review, done, canceled). Drives the real UI through the sidebar row menu, but changes nothing on screen. Fails if the sidebar section holding that row is collapsed, because a collapsed row is invisible to Accessibility and there is no fallback.',
@@ -301,8 +482,8 @@ export function createTools(call) {
301
482
  run: async (args) => {
302
483
  const id = need(args, 'workspace_id');
303
484
  const status = need(args, 'status');
304
- const data = await call(`/api/workspaces/${encodeURIComponent(id)}/status`, {
305
- method: 'POST',
485
+ const data = await call(routes.workspaceStatus.path(id), {
486
+ method: routes.workspaceStatus.method,
306
487
  body: { status },
307
488
  timeoutMs: WRITE_TIMEOUT_MS
308
489
  });
@@ -310,6 +491,112 @@ export function createTools(call) {
310
491
  throw new Error(data.error ?? 'the status change did not land');
311
492
  return `status set to ${status}`;
312
493
  }
494
+ },
495
+ {
496
+ name: 'dismiss_prompt',
497
+ description: 'Throw away a prompt the relay is still holding — a new workspace’s first prompt waiting on setup, or one parked behind a locked Mac. Touches no UI. list_workspaces shows these; a failed one stays visible until it is dismissed, on purpose. Pass session_id for a parked prompt, workspace_id for a first prompt.',
498
+ inputSchema: {
499
+ type: 'object',
500
+ properties: {
501
+ session_id: { type: 'string', description: 'Dismiss the prompt parked for this chat.' },
502
+ workspace_id: { type: 'string', description: 'Dismiss this workspace’s undelivered first prompt.' }
503
+ }
504
+ },
505
+ run: async (args) => {
506
+ const sessionId = str(args.session_id);
507
+ const workspaceId = str(args.workspace_id);
508
+ // Both would be two deletes wearing one name, and the caller could not tell which
509
+ // half failed — so it is one or the other, never both.
510
+ if (!sessionId && !workspaceId)
511
+ throw new Error('pass session_id or workspace_id');
512
+ if (sessionId && workspaceId)
513
+ throw new Error('pass session_id or workspace_id, not both');
514
+ const route = sessionId ? routes.dismissParkedPrompt : routes.dismissFirstPrompt;
515
+ await call(route.path((sessionId ?? workspaceId)), { method: route.method });
516
+ return 'dismissed';
517
+ }
518
+ },
519
+ {
520
+ name: 'keep_awake',
521
+ description: 'Read or set the window that holds this Mac awake with the lid shut. Touches no UI. This is what keeps the relay reachable at all, so a long unattended run wants it: without it a closed lid sleeps the Mac and every agent on it stops. Needs `conductor-remote nosleep setup` to have been run once; without that the status says so and holding fails. Releasing a window while the lid is shut sleeps the Mac straight away.',
522
+ inputSchema: {
523
+ type: 'object',
524
+ properties: {
525
+ action: {
526
+ type: 'string',
527
+ enum: ['status', 'hold', 'release'],
528
+ description: 'Default status.'
529
+ },
530
+ seconds: { type: 'number', description: 'How long to hold it awake. Required for hold.' }
531
+ }
532
+ },
533
+ run: async (args) => {
534
+ const action = str(args.action) ?? 'status';
535
+ if (action === 'status') {
536
+ const s = await call(routes.nosleep.path());
537
+ if (!s.available)
538
+ return 'unavailable — run `conductor-remote nosleep setup` on the Mac first';
539
+ if (!s.armed)
540
+ return `sleeping normally; a window can be up to ${s.maxSeconds}s`;
541
+ return `awake${s.until ? `, expires ${relative(s.until)}` : ' until stopped'} (pid ${s.pid})`;
542
+ }
543
+ if (action === 'release') {
544
+ const r = await call(routes.disarmNoSleep.path(), { method: routes.disarmNoSleep.method });
545
+ if (!r.ok)
546
+ throw new Error(r.error ?? 'could not release the window');
547
+ return r.willSleep ? 'released — the lid is shut, so the Mac is going to sleep now' : 'released';
548
+ }
549
+ if (action !== 'hold')
550
+ throw new Error(`unknown action ${action}`);
551
+ const seconds = num(args.seconds);
552
+ if (seconds === undefined)
553
+ throw new Error('seconds is required for hold');
554
+ const r = await call(routes.armNoSleep.path(), {
555
+ method: routes.armNoSleep.method,
556
+ body: { seconds }
557
+ });
558
+ if (!r.ok)
559
+ throw new Error(r.error ?? 'could not hold the Mac awake');
560
+ return `awake${r.state.until ? `, expires ${relative(r.state.until)}` : ''}`;
561
+ }
562
+ },
563
+ {
564
+ name: 'relay_logs',
565
+ description: 'The relay’s own log — why a send failed, whether Conductor refused Accessibility, what the network did. Touches no UI. Default is the running relay’s captured console; `file` tails the daemon’s log on disk, which is the only place a *previous* process’s crash survives. Secrets are redacted before it leaves the relay.',
566
+ inputSchema: {
567
+ type: 'object',
568
+ properties: {
569
+ file: {
570
+ type: 'string',
571
+ enum: ['relay.log', 'relay.err.log'],
572
+ description: 'Omit for this process’s live log.'
573
+ },
574
+ limit: { type: 'number', description: 'Most recent N lines. Default 200, max 2000.' },
575
+ contains: {
576
+ type: 'string',
577
+ description: 'Keep only lines containing this (case-insensitive). Filtered here, not by the relay.'
578
+ }
579
+ }
580
+ },
581
+ run: async (args) => {
582
+ const file = str(args.file);
583
+ const limit = Math.min(2000, Math.max(1, Math.trunc(num(args.limit) ?? 200)));
584
+ const q = new URLSearchParams({ limit: String(limit) });
585
+ if (file)
586
+ q.set('file', file);
587
+ const data = await call(`${routes.logs.path()}?${q}`);
588
+ const needle = str(args.contains)?.toLowerCase();
589
+ const kept = needle ? data.entries.filter(e => e.text.toLowerCase().includes(needle)) : data.entries;
590
+ if (!kept.length)
591
+ return needle ? `no line in ${data.source} matches ${needle}` : `${data.source} is empty`;
592
+ const lines = kept.map(e => `${stamp(e.t)} ${e.level === 'info' ? '' : `${e.level.toUpperCase()} `}${clip(e.text, 2000)}`);
593
+ // Whose log this is decides what it proves: an unmanaged relay's files belong to a
594
+ // *different* process, so a clean tail there says nothing about the one just called.
595
+ const head = data.managed
596
+ ? data.source
597
+ : `${data.source} (written by the LaunchAgent, not the relay just called)`;
598
+ return [`— ${head}, ${kept.length} lines —`, ...lines].join('\n');
599
+ }
313
600
  }
314
601
  ];
315
602
  }
@@ -269,6 +269,19 @@ export class Reads {
269
269
  }
270
270
  return null;
271
271
  }
272
+ /**
273
+ * Which workspace a chat belongs to.
274
+ *
275
+ * Every other route resolves this by matching `active_session_id`, which only ever
276
+ * finds the tab that is currently on screen. That is fine for a phone, where the
277
+ * chat you are looking at is the chat you are sending to. It is wrong for anything
278
+ * addressing a chat by id — a background tab has a workspace too, and `sessions`
279
+ * has carried `workspace_id` all along.
280
+ */
281
+ sessionWorkspaceId(sessionId) {
282
+ const rows = this.db.query(`SELECT workspace_id FROM sessions WHERE id = ? LIMIT 1`, [sessionId]);
283
+ return rows[0]?.workspace_id ?? null;
284
+ }
272
285
  /** Session → worktree path, cached: it's stable for a session's lifetime and polled every tick. */
273
286
  worktreeBySession = new Map();
274
287
  sessionWorktree(sessionId) {