conductor-remote 1.41.0 → 1.42.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 +74 -0
- package/bin/cli.js +6 -0
- package/dist/assets/{index-BSDnFvjd.js → index-BNMm_aez.js} +2 -2
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/dist-node/src/mcp-tools.js +367 -0
- package/dist-node/src/mcp.js +135 -0
- package/dist-node/src/server.js +606 -489
- package/dist-node/src/writes.js +78 -20
- package/package.json +4 -3
package/dist/index.html
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
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-
|
|
16
|
+
<script type="module" crossorigin src="/assets/index-BNMm_aez.js"></script>
|
|
17
17
|
<link rel="stylesheet" crossorigin href="/assets/index-95IdnA_r.css">
|
|
18
18
|
<link rel="manifest" href="/manifest.webmanifest"></head>
|
|
19
19
|
<body>
|
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 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:"
|
|
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:"16b0826ef326b984cfb211917b831f23"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-BNMm_aez.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\//]}))});
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ten MCP tools, and the JSON-RPC dispatcher that serves them.
|
|
3
|
+
*
|
|
4
|
+
* Shared by both transports, which is the only reason they can't drift: `src/mcp.ts`
|
|
5
|
+
* runs this over stdio as a separate process, and `src/server.ts` mounts the same
|
|
6
|
+
* dispatcher at `POST /mcp` for clients that can only reach a URL.
|
|
7
|
+
*
|
|
8
|
+
* Every tool is an HTTP call to the relay, injected as `call`, and that is the
|
|
9
|
+
* load-bearing decision rather than a convenience. Conductor has one shared window,
|
|
10
|
+
* so the only thing that makes writes safe is `writes.ts` ▸ `uiTurn`, and that lock is
|
|
11
|
+
* *process-local*. A tool that drove AppleScript itself would sit outside it, and two
|
|
12
|
+
* agents focusing different workspaces would land each other's prompts — the exact
|
|
13
|
+
* failure every fail-closed assertion cannot catch. Routed through the relay, the
|
|
14
|
+
* phone, both delivery queues and every agent share one lock.
|
|
15
|
+
*
|
|
16
|
+
* The in-relay transport calls the relay's own API over loopback rather than reaching
|
|
17
|
+
* into `reads`/`writes` directly. That is a sub-millisecond hop against a 1000-line
|
|
18
|
+
* router it would otherwise have to be carved out of, and it keeps *one* code path:
|
|
19
|
+
* a tool cannot behave differently over HTTP than it does over stdio.
|
|
20
|
+
*/
|
|
21
|
+
/** Versions we know how to speak. The client's choice wins when we know it. */
|
|
22
|
+
export const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
|
|
23
|
+
export const SERVER_INFO = { name: 'conductor-remote', version: '1' };
|
|
24
|
+
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.';
|
|
25
|
+
/** Reads are quick; a UI write is measured in tens of seconds (writes.ts ▸ SEND_ATTEMPT_MS). */
|
|
26
|
+
export const READ_TIMEOUT_MS = 10_000;
|
|
27
|
+
export const WRITE_TIMEOUT_MS = 75_000;
|
|
28
|
+
// ── formatting ──────────────────────────────────────────────────────────────────
|
|
29
|
+
// Tool results are text an agent reads, so they are formatted rather than dumped as
|
|
30
|
+
// JSON: half the tokens, and every id an agent needs to chain the next call stays
|
|
31
|
+
// visible instead of buried in a nested object.
|
|
32
|
+
/** Search snippets arrive with control-character hit markers (search.ts ▸ HIT_OPEN). */
|
|
33
|
+
const HIT_OPEN = '\u0001';
|
|
34
|
+
const HIT_CLOSE = '\u0002';
|
|
35
|
+
function unmark(text) {
|
|
36
|
+
return text.replaceAll(HIT_OPEN, '«').replaceAll(HIT_CLOSE, '»').replace(/\s+/g, ' ').trim();
|
|
37
|
+
}
|
|
38
|
+
function clip(text, max) {
|
|
39
|
+
return text.length > max ? `${text.slice(0, max)}… [${text.length - max} more chars]` : text;
|
|
40
|
+
}
|
|
41
|
+
/** Conductor's own title precedence, third copy — see reads.ts ▸ workspaceTitle for why. */
|
|
42
|
+
function label(w) {
|
|
43
|
+
const branch = w.branch ?? '';
|
|
44
|
+
const slug = branch.includes('/') ? branch.slice(branch.indexOf('/') + 1) : branch;
|
|
45
|
+
const words = slug.replace(/[-_]/g, ' ').trim();
|
|
46
|
+
const humanized = words ? words[0].toUpperCase() + words.slice(1) : '';
|
|
47
|
+
return w.workspace_name || w.pr_title || humanized || w.directory_name || w.id.slice(0, 8);
|
|
48
|
+
}
|
|
49
|
+
const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
|
|
50
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
|
|
51
|
+
function need(args, key) {
|
|
52
|
+
const v = str(args[key]);
|
|
53
|
+
if (!v)
|
|
54
|
+
throw new Error(`${key} is required`);
|
|
55
|
+
return v;
|
|
56
|
+
}
|
|
57
|
+
/** Build the tool set against a given relay transport. */
|
|
58
|
+
export function createTools(call) {
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
name: 'search_chats',
|
|
62
|
+
description: 'Full-text search every Conductor chat on this Mac, archived workspaces included, and get back the workspaces that discussed it with the matching excerpts. Use this to answer "which workspace did I do X in" or "what did we decide about X". Searches the prompts the user typed and the agent replies, not tool output. Results carry workspace_id and session_id for read_chat.',
|
|
63
|
+
inputSchema: {
|
|
64
|
+
type: 'object',
|
|
65
|
+
properties: {
|
|
66
|
+
query: { type: 'string', description: 'Plain words. Punctuation and operators are ignored, not parsed.' },
|
|
67
|
+
limit: { type: 'number', description: 'Max workspaces to return (default 12, max 50).' }
|
|
68
|
+
},
|
|
69
|
+
required: ['query']
|
|
70
|
+
},
|
|
71
|
+
run: async (args) => {
|
|
72
|
+
const query = need(args, 'query');
|
|
73
|
+
const limit = num(args.limit);
|
|
74
|
+
const data = await call(`/api/search?q=${encodeURIComponent(query)}${limit ? `&limit=${limit}` : ''}`);
|
|
75
|
+
const lines = [];
|
|
76
|
+
if (data.index.error)
|
|
77
|
+
lines.push(`! chat index unavailable (${data.index.error}) — names matched only`);
|
|
78
|
+
else if (!data.index.ready)
|
|
79
|
+
lines.push(`! still indexing (${Math.round(data.index.progress * 100)}%) — older chats not searchable yet`);
|
|
80
|
+
if (!data.results.length)
|
|
81
|
+
return [...lines, `no workspace or chat matches ${JSON.stringify(query)}`].join('\n');
|
|
82
|
+
for (const r of data.results) {
|
|
83
|
+
const w = r.workspace;
|
|
84
|
+
const tags = [w.repo_name, w.branch, w.archived ? 'ARCHIVED' : w.state].filter(Boolean).join(' · ');
|
|
85
|
+
lines.push('');
|
|
86
|
+
lines.push(`## ${label(w)}`);
|
|
87
|
+
lines.push(`${tags}${r.byName ? ' · name match' : ''}`);
|
|
88
|
+
lines.push(`workspace_id: ${w.id}${r.sessionId ? ` session_id: ${r.sessionId}` : ''}`);
|
|
89
|
+
if (r.hits)
|
|
90
|
+
lines.push(`${r.hits} matching message${r.hits === 1 ? '' : 's'}:`);
|
|
91
|
+
for (const s of r.snippets)
|
|
92
|
+
lines.push(` [${s.role}] ${clip(unmark(s.text), 400)}`);
|
|
93
|
+
}
|
|
94
|
+
return lines.join('\n').trim();
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: 'read_chat',
|
|
99
|
+
description: 'Read a Conductor chat transcript by session_id, newest messages last. Works for archived workspaces, which is how you read work that has been put away. Tool calls and tool output are summarised to one line each; the prose is verbatim.',
|
|
100
|
+
inputSchema: {
|
|
101
|
+
type: 'object',
|
|
102
|
+
properties: {
|
|
103
|
+
session_id: { type: 'string', description: 'From search_chats or list_chats.' },
|
|
104
|
+
limit: { type: 'number', description: 'How many trailing entries to return (default 40, max 400).' },
|
|
105
|
+
include_tools: { type: 'boolean', description: 'Include tool-call and thinking rows (default false).' }
|
|
106
|
+
},
|
|
107
|
+
required: ['session_id']
|
|
108
|
+
},
|
|
109
|
+
run: async (args) => {
|
|
110
|
+
const sessionId = need(args, 'session_id');
|
|
111
|
+
const limit = Math.min(400, Math.max(1, num(args.limit) ?? 40));
|
|
112
|
+
const includeTools = args.include_tools === true;
|
|
113
|
+
const data = await call(`/api/sessions/${encodeURIComponent(sessionId)}/messages?after=0`, { timeoutMs: 30_000 });
|
|
114
|
+
const wanted = includeTools
|
|
115
|
+
? data.entries
|
|
116
|
+
: data.entries.filter(e => e.role === 'user' || e.role === 'assistant');
|
|
117
|
+
if (!wanted.length)
|
|
118
|
+
return `no messages in session ${sessionId}`;
|
|
119
|
+
const tail = wanted.slice(-limit);
|
|
120
|
+
const head = tail.length < wanted.length ? [`(last ${tail.length} of ${wanted.length} entries)`] : [];
|
|
121
|
+
return [
|
|
122
|
+
...head,
|
|
123
|
+
...tail.map(e => {
|
|
124
|
+
if (e.role === 'tool')
|
|
125
|
+
return `[tool ${e.tool ?? ''}] ${clip(e.text, 200)}${e.detail ? ` — ${e.detail}` : ''}`;
|
|
126
|
+
return `[${e.role}] ${clip(e.text, 4000)}`;
|
|
127
|
+
})
|
|
128
|
+
].join('\n\n');
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: 'list_workspaces',
|
|
133
|
+
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.',
|
|
134
|
+
inputSchema: {
|
|
135
|
+
type: 'object',
|
|
136
|
+
properties: {
|
|
137
|
+
status: {
|
|
138
|
+
type: 'string',
|
|
139
|
+
description: 'Filter by live agent status: working | idle | error. Omit for all.'
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
run: async (args) => {
|
|
144
|
+
const status = str(args.status);
|
|
145
|
+
const data = await call('/api/state');
|
|
146
|
+
const shown = status ? data.workspaces.filter(w => w.session_status === status) : data.workspaces;
|
|
147
|
+
if (!shown.length)
|
|
148
|
+
return status ? `no workspace is ${status}` : 'no live workspaces';
|
|
149
|
+
return shown
|
|
150
|
+
.map(w => [
|
|
151
|
+
`${w.session_status === 'working' ? '▶' : '·'} ${label(w)}`,
|
|
152
|
+
` ${[w.repo_name, w.branch, w.model, w.pr_number ? `PR #${w.pr_number} ${w.pr_status ?? ''}`.trim() : null].filter(Boolean).join(' · ')}`,
|
|
153
|
+
` workspace_id: ${w.id}${w.active_session_id ? ` session_id: ${w.active_session_id}` : ''}`
|
|
154
|
+
].join('\n'))
|
|
155
|
+
.join('\n');
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: 'list_chats',
|
|
160
|
+
description: 'List the chat tabs in a workspace, with each one’s status and when its current turn started. A workspace can hold several conversations; send_prompt and read_chat address one of them.',
|
|
161
|
+
inputSchema: {
|
|
162
|
+
type: 'object',
|
|
163
|
+
properties: { workspace_id: { type: 'string' } },
|
|
164
|
+
required: ['workspace_id']
|
|
165
|
+
},
|
|
166
|
+
run: async (args) => {
|
|
167
|
+
const id = need(args, 'workspace_id');
|
|
168
|
+
const data = await call(`/api/workspaces/${encodeURIComponent(id)}/sessions`);
|
|
169
|
+
if (!data.sessions.length)
|
|
170
|
+
return `no chats in workspace ${id}`;
|
|
171
|
+
return data.sessions
|
|
172
|
+
.map(s => `${s.status === 'working' ? '▶' : '·'} ${s.title ?? '(untitled)'} — ${s.status ?? '?'} · ${s.model ?? '?'}\n session_id: ${s.id}`)
|
|
173
|
+
.join('\n');
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: 'workspace_diff',
|
|
178
|
+
description: 'The git diff of a live workspace against its target branch, untracked files included.',
|
|
179
|
+
inputSchema: {
|
|
180
|
+
type: 'object',
|
|
181
|
+
properties: { workspace_id: { type: 'string' } },
|
|
182
|
+
required: ['workspace_id']
|
|
183
|
+
},
|
|
184
|
+
run: async (args) => {
|
|
185
|
+
const id = need(args, 'workspace_id');
|
|
186
|
+
const data = await call(`/api/workspaces/${encodeURIComponent(id)}/diff`, { timeoutMs: 30_000 });
|
|
187
|
+
if (!data.files?.length)
|
|
188
|
+
return 'no changes against the target branch';
|
|
189
|
+
return data.files.map(f => `${f.path} +${f.additions} -${f.deletions}`).join('\n');
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
name: 'list_repos',
|
|
194
|
+
description: 'The repos Conductor can create a workspace in. Use before create_workspace to get an exact repo name.',
|
|
195
|
+
inputSchema: { type: 'object', properties: {} },
|
|
196
|
+
run: async () => {
|
|
197
|
+
const data = await call('/api/repos');
|
|
198
|
+
return data.repos.map(r => `${r.name} (${r.default_branch ?? '?'}) ${r.root_path ?? ''}`).join('\n');
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
name: 'create_workspace',
|
|
203
|
+
description: 'Start a new Conductor workspace in a repo, optionally with a first prompt. This is the one write that touches no UI: it opens a Conductor deep link, so it needs no Accessibility and steals no focus. Returns as soon as the workspace row exists (~2s); the worktree may still be setting up and the relay delivers the first prompt on its own schedule once it is ready.',
|
|
204
|
+
inputSchema: {
|
|
205
|
+
type: 'object',
|
|
206
|
+
properties: {
|
|
207
|
+
repo: { type: 'string', description: 'Exact name from list_repos.' },
|
|
208
|
+
prompt: { type: 'string', description: 'First prompt for the new agent. Omit to open an empty workspace.' },
|
|
209
|
+
wait_for_send: {
|
|
210
|
+
type: 'boolean',
|
|
211
|
+
description: 'Block until the first prompt is actually delivered (can take 30s+). Default false.'
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
required: ['repo']
|
|
215
|
+
},
|
|
216
|
+
run: async (args) => {
|
|
217
|
+
const repo = need(args, 'repo');
|
|
218
|
+
const prompt = str(args.prompt);
|
|
219
|
+
const send = args.wait_for_send === true;
|
|
220
|
+
const data = await call('/api/workspaces', {
|
|
221
|
+
method: 'POST',
|
|
222
|
+
body: { repo, prompt, send },
|
|
223
|
+
timeoutMs: send ? WRITE_TIMEOUT_MS : 30_000
|
|
224
|
+
});
|
|
225
|
+
const lines = [
|
|
226
|
+
`created ${data.workspace ? label(data.workspace) : data.workspaceId}`,
|
|
227
|
+
`workspace_id: ${data.workspaceId}`
|
|
228
|
+
];
|
|
229
|
+
if (data.warning)
|
|
230
|
+
lines.push(`! ${data.warning}`);
|
|
231
|
+
else if (data.pendingPrompt && !data.sent)
|
|
232
|
+
lines.push('the first prompt is queued — the relay sends it once the worktree is ready');
|
|
233
|
+
else if (data.sent)
|
|
234
|
+
lines.push('the first prompt was delivered');
|
|
235
|
+
return lines.join('\n');
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
name: 'send_prompt',
|
|
240
|
+
description: 'Send a prompt into an existing Conductor chat, exactly as typing it on the Mac would. This DRIVES THE REAL UI: it focuses the workspace, selects the chat tab and presses Enter, so it steals focus for a few seconds. If that chat is already working, the message STEERS the running agent rather than starting a new turn — do not use it to poll or test. Ask the user before sending into a chat they did not name.',
|
|
241
|
+
inputSchema: {
|
|
242
|
+
type: 'object',
|
|
243
|
+
properties: {
|
|
244
|
+
session_id: { type: 'string', description: 'The chat to send to (list_chats / search_chats).' },
|
|
245
|
+
workspace_id: {
|
|
246
|
+
type: 'string',
|
|
247
|
+
description: 'Its workspace. Strongly recommended: it is what the relay asserts against before typing.'
|
|
248
|
+
},
|
|
249
|
+
text: { type: 'string' }
|
|
250
|
+
},
|
|
251
|
+
required: ['session_id', 'text']
|
|
252
|
+
},
|
|
253
|
+
run: async (args) => {
|
|
254
|
+
const sessionId = need(args, 'session_id');
|
|
255
|
+
const text = need(args, 'text');
|
|
256
|
+
const data = await call(`/api/sessions/${encodeURIComponent(sessionId)}/prompt`, { method: 'POST', body: { text, workspaceId: str(args.workspace_id) }, timeoutMs: WRITE_TIMEOUT_MS });
|
|
257
|
+
if (data.parked)
|
|
258
|
+
return 'the Mac is locked — the prompt is parked and will be sent on unlock';
|
|
259
|
+
if (!data.ok)
|
|
260
|
+
throw new Error(data.error ?? 'the send did not land');
|
|
261
|
+
return data.warning ? `sent (${data.warning})` : 'sent';
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
name: 'stop_turn',
|
|
266
|
+
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.',
|
|
267
|
+
inputSchema: {
|
|
268
|
+
type: 'object',
|
|
269
|
+
properties: {
|
|
270
|
+
session_id: { type: 'string' },
|
|
271
|
+
workspace_id: {
|
|
272
|
+
type: 'string',
|
|
273
|
+
description: 'Required in practice — the relay asserts the pane against it before pressing.'
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
required: ['session_id']
|
|
277
|
+
},
|
|
278
|
+
run: async (args) => {
|
|
279
|
+
const sessionId = need(args, 'session_id');
|
|
280
|
+
const data = await call(`/api/sessions/${encodeURIComponent(sessionId)}/stop`, { method: 'POST', body: { workspaceId: str(args.workspace_id) }, timeoutMs: WRITE_TIMEOUT_MS });
|
|
281
|
+
if (data.alreadyIdle)
|
|
282
|
+
return 'that chat had already finished — nothing to stop';
|
|
283
|
+
if (!data.ok)
|
|
284
|
+
throw new Error(data.error ?? 'the stop did not land');
|
|
285
|
+
return 'stopped';
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
name: 'set_workspace_status',
|
|
290
|
+
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.',
|
|
291
|
+
inputSchema: {
|
|
292
|
+
type: 'object',
|
|
293
|
+
properties: {
|
|
294
|
+
workspace_id: { type: 'string' },
|
|
295
|
+
status: { type: 'string', enum: ['backlog', 'in-progress', 'in-review', 'done', 'canceled'] }
|
|
296
|
+
},
|
|
297
|
+
required: ['workspace_id', 'status']
|
|
298
|
+
},
|
|
299
|
+
run: async (args) => {
|
|
300
|
+
const id = need(args, 'workspace_id');
|
|
301
|
+
const status = need(args, 'status');
|
|
302
|
+
const data = await call(`/api/workspaces/${encodeURIComponent(id)}/status`, {
|
|
303
|
+
method: 'POST',
|
|
304
|
+
body: { status },
|
|
305
|
+
timeoutMs: WRITE_TIMEOUT_MS
|
|
306
|
+
});
|
|
307
|
+
if (!data.ok)
|
|
308
|
+
throw new Error(data.error ?? 'the status change did not land');
|
|
309
|
+
return `status set to ${status}`;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
];
|
|
313
|
+
}
|
|
314
|
+
const ok = (id, result) => ({ jsonrpc: '2.0', id: id ?? null, result });
|
|
315
|
+
const err = (id, code, message) => ({
|
|
316
|
+
jsonrpc: '2.0',
|
|
317
|
+
id: id ?? null,
|
|
318
|
+
error: { code, message }
|
|
319
|
+
});
|
|
320
|
+
/**
|
|
321
|
+
* Handle one JSON-RPC message. Returns null for a notification, which by spec takes
|
|
322
|
+
* no reply at all — stdio writes nothing and HTTP answers 202.
|
|
323
|
+
*/
|
|
324
|
+
export async function handleRpc(tools, req) {
|
|
325
|
+
const notification = req.id === undefined || req.id === null;
|
|
326
|
+
switch (req.method) {
|
|
327
|
+
case 'initialize': {
|
|
328
|
+
const asked = str(req.params?.protocolVersion);
|
|
329
|
+
return ok(req.id, {
|
|
330
|
+
// Echo the client's version when we know it, else name our newest. A client
|
|
331
|
+
// that can't live with the answer disconnects, which is the spec's own path.
|
|
332
|
+
protocolVersion: asked && PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
|
|
333
|
+
capabilities: { tools: {} },
|
|
334
|
+
serverInfo: SERVER_INFO,
|
|
335
|
+
instructions: INSTRUCTIONS
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
case 'notifications/initialized':
|
|
339
|
+
case 'notifications/cancelled':
|
|
340
|
+
return null;
|
|
341
|
+
case 'ping':
|
|
342
|
+
return notification ? null : ok(req.id, {});
|
|
343
|
+
case 'tools/list':
|
|
344
|
+
return ok(req.id, {
|
|
345
|
+
tools: tools.map(t => ({ name: t.name, description: t.description, inputSchema: t.inputSchema }))
|
|
346
|
+
});
|
|
347
|
+
case 'tools/call': {
|
|
348
|
+
const name = str(req.params?.name);
|
|
349
|
+
const tool = tools.find(t => t.name === name);
|
|
350
|
+
if (!tool)
|
|
351
|
+
return err(req.id, -32602, `unknown tool: ${name}`);
|
|
352
|
+
const args = req.params?.arguments ?? {};
|
|
353
|
+
try {
|
|
354
|
+
const text = await tool.run(args);
|
|
355
|
+
return ok(req.id, { content: [{ type: 'text', text: text || '(no output)' }] });
|
|
356
|
+
}
|
|
357
|
+
catch (e) {
|
|
358
|
+
// A tool failure is a result the model should see and can act on, not a
|
|
359
|
+
// protocol error that would hide the reason behind a transport code.
|
|
360
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
361
|
+
return ok(req.id, { content: [{ type: 'text', text: message }], isError: true });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
default:
|
|
365
|
+
return notification ? null : err(req.id, -32601, `method not found: ${req.method}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import { stateDir } from "./config.js";
|
|
5
|
+
import { createTools, handleRpc, READ_TIMEOUT_MS } from "./mcp-tools.js";
|
|
6
|
+
/**
|
|
7
|
+
* MCP over stdio: `conductor-remote mcp`.
|
|
8
|
+
*
|
|
9
|
+
* The transport a local agent gets — the client spawns this as a child process and
|
|
10
|
+
* talks newline-delimited JSON-RPC 2.0 over its stdin and stdout. The tools
|
|
11
|
+
* themselves live in `mcp-tools.ts`, shared with the relay's own `POST /mcp` so the
|
|
12
|
+
* two transports cannot drift.
|
|
13
|
+
*
|
|
14
|
+
* Nothing here authenticates the *stdio* channel, and nothing needs to: whatever can
|
|
15
|
+
* spawn this process already runs as you on this Mac and could read the token file
|
|
16
|
+
* directly. The hop that does carry a credential is the one to the relay.
|
|
17
|
+
*
|
|
18
|
+
* Hand-rolled rather than built on `@modelcontextprotocol/sdk`, which measures 91
|
|
19
|
+
* packages / 24 MB (express, hono, cors, jose) for a server that speaks neither HTTP
|
|
20
|
+
* nor OAuth. The relay's tarball keeps its zero runtime dependencies, which matters
|
|
21
|
+
* more than usual here: it auto-updates itself while holding a token that drives your
|
|
22
|
+
* Mac.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* stdout is the wire. A stray `console.log` from anywhere in this process would be
|
|
26
|
+
* parsed as a protocol message and kill the session, so the one shared mistake is
|
|
27
|
+
* made impossible up front rather than guarded against per call site.
|
|
28
|
+
*/
|
|
29
|
+
console.log = (...args) => console.error(...args);
|
|
30
|
+
console.info = (...args) => console.error(...args);
|
|
31
|
+
function relayBase() {
|
|
32
|
+
const port = process.env.RELAY_PORT ?? '8787';
|
|
33
|
+
// The relay binds loopback (see config.ts); RELAY_HOST only widens who else can
|
|
34
|
+
// reach it, so 127.0.0.1 is always right for a client on the same Mac.
|
|
35
|
+
return `http://127.0.0.1:${port}`;
|
|
36
|
+
}
|
|
37
|
+
function relayToken() {
|
|
38
|
+
if (process.env.RELAY_TOKEN)
|
|
39
|
+
return process.env.RELAY_TOKEN;
|
|
40
|
+
try {
|
|
41
|
+
return readFileSync(path.join(stateDir(), 'token'), 'utf8').trim();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new Error(`no relay token at ${path.join(stateDir(), 'token')} — start the relay once (conductor-remote service install), or set RELAY_TOKEN`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function call(route, opts = {}) {
|
|
48
|
+
const timeoutMs = opts.timeoutMs ?? READ_TIMEOUT_MS;
|
|
49
|
+
let res;
|
|
50
|
+
try {
|
|
51
|
+
res = await fetch(`${relayBase()}${route}`, {
|
|
52
|
+
method: opts.method ?? 'GET',
|
|
53
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
54
|
+
headers: {
|
|
55
|
+
authorization: `Bearer ${relayToken()}`,
|
|
56
|
+
'content-type': 'application/json',
|
|
57
|
+
// Marks this caller as an agent: the relay drops it to background priority on
|
|
58
|
+
// the UI lock, behind anyone using the phone (server.ts ▸ withUiPriority).
|
|
59
|
+
'x-relay-client': 'mcp',
|
|
60
|
+
// The relay retries a failed send inside this budget and never past it, so
|
|
61
|
+
// stating it here is what stops it outliving us — see server.ts ▸ sendBudget.
|
|
62
|
+
'x-client-timeout-ms': String(timeoutMs)
|
|
63
|
+
},
|
|
64
|
+
body: opts.body === undefined ? undefined : JSON.stringify(opts.body)
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
if (err instanceof DOMException && err.name === 'TimeoutError')
|
|
69
|
+
throw new Error(`the relay did not answer within ${Math.round(timeoutMs / 1000)}s`);
|
|
70
|
+
throw new Error(`cannot reach the relay at ${relayBase()} (${err instanceof Error ? err.message : err}). Is it running? \`conductor-remote service status\``);
|
|
71
|
+
}
|
|
72
|
+
const payload = (await res.json().catch(() => ({})));
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
// 503 is the UI lock refusing a deep queue, and it is worth naming as such: it
|
|
75
|
+
// means "retry shortly", not "this failed".
|
|
76
|
+
const busy = res.status === 503 ? ' (Conductor’s UI is busy — retry shortly)' : '';
|
|
77
|
+
throw new Error(`${payload.error || `HTTP ${res.status}`}${busy}`);
|
|
78
|
+
}
|
|
79
|
+
return payload;
|
|
80
|
+
}
|
|
81
|
+
const tools = createTools(call);
|
|
82
|
+
function write(message) {
|
|
83
|
+
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* In-flight calls, so end-of-stdin doesn't kill work that hasn't answered.
|
|
87
|
+
*
|
|
88
|
+
* A tool call is an await on the relay, and a UI write can take tens of seconds.
|
|
89
|
+
* Exiting straight from the `close` event drops every one of those on the floor —
|
|
90
|
+
* which is silent, because the reply that never came looks exactly like a client
|
|
91
|
+
* that stopped listening.
|
|
92
|
+
*/
|
|
93
|
+
const inFlight = new Set();
|
|
94
|
+
let stdinClosed = false;
|
|
95
|
+
function exitWhenDrained() {
|
|
96
|
+
if (stdinClosed && inFlight.size === 0)
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}
|
|
99
|
+
const lines = createInterface({ input: process.stdin });
|
|
100
|
+
lines.on('line', line => {
|
|
101
|
+
const trimmed = line.trim();
|
|
102
|
+
if (!trimmed)
|
|
103
|
+
return;
|
|
104
|
+
let req;
|
|
105
|
+
try {
|
|
106
|
+
req = JSON.parse(trimmed);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// JSON-RPC 2.0: a parse error is answered with a null id, because there is no id
|
|
110
|
+
// to echo.
|
|
111
|
+
return write({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } });
|
|
112
|
+
}
|
|
113
|
+
// Concurrent by design: sequencing is the client's job, and the writes that truly
|
|
114
|
+
// cannot overlap are serialized by the relay's own UI lock, not by this loop.
|
|
115
|
+
const done = handleRpc(tools, req)
|
|
116
|
+
.then(res => {
|
|
117
|
+
if (res)
|
|
118
|
+
write(res);
|
|
119
|
+
})
|
|
120
|
+
.catch(err => write({
|
|
121
|
+
jsonrpc: '2.0',
|
|
122
|
+
id: req.id ?? null,
|
|
123
|
+
error: { code: -32603, message: err instanceof Error ? err.message : String(err) }
|
|
124
|
+
}))
|
|
125
|
+
.finally(() => {
|
|
126
|
+
inFlight.delete(done);
|
|
127
|
+
exitWhenDrained();
|
|
128
|
+
});
|
|
129
|
+
inFlight.add(done);
|
|
130
|
+
});
|
|
131
|
+
lines.on('close', () => {
|
|
132
|
+
stdinClosed = true;
|
|
133
|
+
exitWhenDrained();
|
|
134
|
+
});
|
|
135
|
+
console.error(`conductor-remote mcp — ${tools.length} tools, relay at ${relayBase()}`);
|