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