conductor-remote 1.42.3 → 1.44.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 +25 -3
- package/dist/assets/index-BGOFh2vS.js +42 -0
- package/dist/assets/index-BSIBXmnj.css +1 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/src/attachments.js +80 -0
- package/dist-node/src/mcp-tools.js +313 -26
- package/dist-node/src/reads.js +33 -0
- package/dist-node/src/routes.js +98 -0
- package/dist-node/src/server.js +175 -63
- package/dist-node/src/transcript.js +80 -0
- package/dist-node/src/writes.js +18 -0
- package/package.json +4 -2
- package/dist/assets/index-C3e9RRjA.css +0 -1
- package/dist/assets/index-C_WDyBGp.js +0 -42
|
@@ -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
|
@@ -190,6 +190,26 @@ export class Reads {
|
|
|
190
190
|
LIMIT ?`, [...params, limit]);
|
|
191
191
|
return rows.map(toSearchWorkspace);
|
|
192
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* One workspace by id, whatever state it is in — the read behind opening an archived
|
|
195
|
+
* chat (`GET /api/workspaces/:id`).
|
|
196
|
+
*
|
|
197
|
+
* `getWorkspace` above is the *live* one: it resolves a worktree and a base branch,
|
|
198
|
+
* and returns null for the 1,846 archived workspaces here, which is right for every
|
|
199
|
+
* write (there is nothing to focus and nothing to diff) and wrong for reading. The
|
|
200
|
+
* transcript survives archiving — Conductor deletes the worktree, not the chat — so
|
|
201
|
+
* this returns the same `SearchWorkspace` a search result carries, with no worktree
|
|
202
|
+
* and no git, and `listSessions`/`getMessages` do the rest by id.
|
|
203
|
+
*/
|
|
204
|
+
getAnyWorkspace(id) {
|
|
205
|
+
const rows = this.db.query(`SELECT w.id, w.workspace_name, w.pr_title, w.branch, w.directory_name, w.state, w.updated_at,
|
|
206
|
+
r.name AS repo_name, r.icon AS repo_icon, r.root_path AS repo_root, r.remote_url AS remote_url
|
|
207
|
+
FROM workspaces w
|
|
208
|
+
LEFT JOIN repos r ON r.id = w.repository_id
|
|
209
|
+
WHERE w.id = ?
|
|
210
|
+
LIMIT 1`, [id]);
|
|
211
|
+
return rows[0] ? toSearchWorkspace(rows[0]) : null;
|
|
212
|
+
}
|
|
193
213
|
/** Resolve a repo's icon by its name (the sidebar avatar) — null if the repo or icon is unknown. */
|
|
194
214
|
resolveRepoIcon(repoName) {
|
|
195
215
|
const rows = this.db.query('SELECT root_path FROM repos WHERE name = ? LIMIT 1', [
|
|
@@ -269,6 +289,19 @@ export class Reads {
|
|
|
269
289
|
}
|
|
270
290
|
return null;
|
|
271
291
|
}
|
|
292
|
+
/**
|
|
293
|
+
* Which workspace a chat belongs to.
|
|
294
|
+
*
|
|
295
|
+
* Every other route resolves this by matching `active_session_id`, which only ever
|
|
296
|
+
* finds the tab that is currently on screen. That is fine for a phone, where the
|
|
297
|
+
* chat you are looking at is the chat you are sending to. It is wrong for anything
|
|
298
|
+
* addressing a chat by id — a background tab has a workspace too, and `sessions`
|
|
299
|
+
* has carried `workspace_id` all along.
|
|
300
|
+
*/
|
|
301
|
+
sessionWorkspaceId(sessionId) {
|
|
302
|
+
const rows = this.db.query(`SELECT workspace_id FROM sessions WHERE id = ? LIMIT 1`, [sessionId]);
|
|
303
|
+
return rows[0]?.workspace_id ?? null;
|
|
304
|
+
}
|
|
272
305
|
/** Session → worktree path, cached: it's stable for a session's lifetime and polled every tick. */
|
|
273
306
|
worktreeBySession = new Map();
|
|
274
307
|
sessionWorktree(sessionId) {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every `/api` path, declared once, for the three callers that must agree on it.
|
|
3
|
+
*
|
|
4
|
+
* The relay matches these, the phone builds them (`web/src/lib/api.ts`) and the MCP
|
|
5
|
+
* tools build them too (`src/mcp-tools.ts`). Before this each path was spelled three
|
|
6
|
+
* times: a regex here, a template literal there, another template literal in the third
|
|
7
|
+
* place. `src/wire.ts` had already made the *shapes* impossible to disagree about, and
|
|
8
|
+
* this is the other half — a renamed path used to typecheck cleanly in all three files
|
|
9
|
+
* and surface as a 404 on someone's phone.
|
|
10
|
+
*
|
|
11
|
+
* One pattern gives both directions. `param()` splits `/api/sessions/:id/stop` at the
|
|
12
|
+
* placeholder, so the same string builds `path(id)` for a client and the regex the relay
|
|
13
|
+
* matches with. They cannot drift because there is only one of them.
|
|
14
|
+
*
|
|
15
|
+
* **This module stays stdlib-free — no `node:` imports, ever.** It is one of the two
|
|
16
|
+
* files under `src/` the web app may import a *value* from (the other is
|
|
17
|
+
* `src/shared.ts`), and `scripts/check-imports.ts` walks both to enforce it. Anything
|
|
18
|
+
* needing Node belongs in the handler, not in the table.
|
|
19
|
+
*/
|
|
20
|
+
/** Escape a literal for use inside a RegExp — the paths are fixed strings, but `.` is real syntax. */
|
|
21
|
+
function literal(text) {
|
|
22
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
23
|
+
}
|
|
24
|
+
function flat(method, pattern) {
|
|
25
|
+
return { method, pattern, path: () => pattern };
|
|
26
|
+
}
|
|
27
|
+
function param(method, pattern) {
|
|
28
|
+
const [head, tail] = pattern.split(/:[a-zA-Z]+/);
|
|
29
|
+
if (tail === undefined)
|
|
30
|
+
throw new Error(`route ${pattern} declares no :param`);
|
|
31
|
+
return {
|
|
32
|
+
method,
|
|
33
|
+
pattern,
|
|
34
|
+
path: value => `${head}${encodeURIComponent(value)}${tail}`,
|
|
35
|
+
re: new RegExp(`^${literal(head)}([^/]+)${literal(tail)}$`)
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The table. Names read as what the caller wants, not as the verb plus the noun, because
|
|
40
|
+
* the method is right there in the value.
|
|
41
|
+
*/
|
|
42
|
+
export const routes = {
|
|
43
|
+
// ── the relay itself ──
|
|
44
|
+
state: flat('GET', '/api/state'),
|
|
45
|
+
search: flat('GET', '/api/search'),
|
|
46
|
+
repos: flat('GET', '/api/repos'),
|
|
47
|
+
repoIcon: param('GET', '/api/repos/:repo/icon'),
|
|
48
|
+
logs: flat('GET', '/api/logs'),
|
|
49
|
+
settings: flat('GET', '/api/settings'),
|
|
50
|
+
updateSettings: flat('PATCH', '/api/settings'),
|
|
51
|
+
nosleep: flat('GET', '/api/nosleep'),
|
|
52
|
+
armNoSleep: flat('POST', '/api/nosleep'),
|
|
53
|
+
disarmNoSleep: flat('DELETE', '/api/nosleep'),
|
|
54
|
+
push: flat('GET', '/api/push'),
|
|
55
|
+
pushSubscribe: flat('POST', '/api/push/subscribe'),
|
|
56
|
+
pushUnsubscribe: flat('POST', '/api/push/unsubscribe'),
|
|
57
|
+
pushTest: flat('POST', '/api/push/test'),
|
|
58
|
+
// ── workspaces ──
|
|
59
|
+
createWorkspace: flat('POST', '/api/workspaces'),
|
|
60
|
+
/** One workspace by id, archived included — what `/api/state` deliberately leaves out. */
|
|
61
|
+
workspace: param('GET', '/api/workspaces/:workspaceId'),
|
|
62
|
+
sessions: param('GET', '/api/workspaces/:workspaceId/sessions'),
|
|
63
|
+
newChat: param('POST', '/api/workspaces/:workspaceId/sessions'),
|
|
64
|
+
diff: param('GET', '/api/workspaces/:workspaceId/diff'),
|
|
65
|
+
merge: param('POST', '/api/workspaces/:workspaceId/merge'),
|
|
66
|
+
workspaceStatus: param('POST', '/api/workspaces/:workspaceId/status'),
|
|
67
|
+
/** Dismiss a first prompt the relay never managed to deliver (src/firstprompt.ts). */
|
|
68
|
+
dismissFirstPrompt: param('DELETE', '/api/workspaces/:workspaceId/prompt'),
|
|
69
|
+
// ── chats ──
|
|
70
|
+
messages: param('GET', '/api/sessions/:sessionId/messages'),
|
|
71
|
+
models: param('GET', '/api/sessions/:sessionId/models'),
|
|
72
|
+
agent: param('POST', '/api/sessions/:sessionId/agent'),
|
|
73
|
+
stop: param('POST', '/api/sessions/:sessionId/stop'),
|
|
74
|
+
sendPrompt: param('POST', '/api/sessions/:sessionId/prompt'),
|
|
75
|
+
/** Copy a chat into a fresh tab beside it, as a Conductor attachment (src/attachments.ts). */
|
|
76
|
+
splitChat: param('POST', '/api/sessions/:sessionId/split'),
|
|
77
|
+
/** Dismiss a prompt parked behind the lock screen (src/parked.ts). */
|
|
78
|
+
dismissParkedPrompt: param('DELETE', '/api/sessions/:sessionId/prompt')
|
|
79
|
+
};
|
|
80
|
+
// ── matching, for the relay ─────────────────────────────────────────────────────
|
|
81
|
+
// The client half of a route is a function call; the server half needs these two.
|
|
82
|
+
/** Whether this request is that parameterless route. */
|
|
83
|
+
export function isRoute(route, method, pathname) {
|
|
84
|
+
return method === route.method && pathname === route.pattern;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The decoded parameter when this request is that route, else null.
|
|
88
|
+
*
|
|
89
|
+
* Decoding here rather than at each call site is the point: a workspace id is safe
|
|
90
|
+
* either way, but a repo name is not, and one handler forgetting `decodeURIComponent`
|
|
91
|
+
* is exactly the bug this table exists to make unwritable.
|
|
92
|
+
*/
|
|
93
|
+
export function routeParam(route, method, pathname) {
|
|
94
|
+
if (method !== route.method)
|
|
95
|
+
return null;
|
|
96
|
+
const m = pathname.match(route.re);
|
|
97
|
+
return m ? decodeURIComponent(m[1]) : null;
|
|
98
|
+
}
|