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/README.md +50 -3
- package/dist/assets/index-4dAWTDxH.css +1 -0
- package/dist/assets/index-CZzXID1o.js +42 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/src/attachments.js +78 -0
- package/dist-node/src/mcp-tools.js +313 -26
- package/dist-node/src/reads.js +13 -0
- package/dist-node/src/routes.js +96 -0
- package/dist-node/src/server.js +165 -63
- package/dist-node/src/transcript.js +80 -0
- package/dist-node/src/writes.js +10 -0
- package/package.json +4 -2
- package/dist/assets/index-95IdnA_r.css +0 -1
- package/dist/assets/index-DFdIcbun.js +0 -42
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-
|
|
17
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
215
|
-
method:
|
|
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(
|
|
251
|
-
method:
|
|
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(
|
|
279
|
-
method:
|
|
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(
|
|
305
|
-
method:
|
|
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
|
}
|
package/dist-node/src/reads.js
CHANGED
|
@@ -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) {
|