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.
@@ -10,6 +10,7 @@ import { FirstPromptQueue } from "./firstprompt.js";
10
10
  import { startFunnelWatchdog } from "./funnel-watchdog.js";
11
11
  import { workspaceDiff } from "./git.js";
12
12
  import { installLogCapture, isManaged, LOG_FILE_NAMES, logFiles, processStartedAt, recentLogs, redactSecrets, tailLogFile } from "./logbuf.js";
13
+ import { createTools, handleRpc, READ_TIMEOUT_MS } from "./mcp-tools.js";
13
14
  import { mergePr } from "./merge.js";
14
15
  import { armNoSleep, disarmNoSleep, MAX_SECONDS as NOSLEEP_MAX_SECONDS, nosleepState, watchNoSleepExpiry } from "./nosleep.js";
15
16
  import { chatRoute, notifyAll, notifyDevice, pushConfig, startNotifier, subscribeDevice, unsubscribeDevice } from "./notify.js";
@@ -20,7 +21,7 @@ import { foldHits, queryTokens, SearchIndex } from "./search.js";
20
21
  import { readSettings, writeSettings } from "./settings.js";
21
22
  import { driftWarningLines, tailscaleBin } from "./tailscale.js";
22
23
  import { autoJoinHotspotMode, currentSsid, looksLikeHotspot, preferredNetworks } from "./wifi.js";
23
- import { createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, retryWontHelp, screenLocked, setAgentOptions, setRestartGuard, setWorkspaceStatus, stopTurn, WORKSPACE_STATUS_LABELS } from "./writes.js";
24
+ import { createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, retryWontHelp, screenLocked, setAgentOptions, setRestartGuard, setWorkspaceStatus, stopTurn, UiBusyError, uiQueueDepth, WORKSPACE_STATUS_LABELS, withUiPriority } from "./writes.js";
24
25
  // Before anything that logs: from here on every console line is also kept in memory for
25
26
  // `GET /api/logs`, so the phone can read why a send failed without ssh-ing into the Mac.
26
27
  installLogCapture();
@@ -33,6 +34,38 @@ const actuator = pickActuator(cfg.writeStrategy);
33
34
  // deleting the file rebuilds it on the next start.
34
35
  const search = new SearchIndex(db, path.join(stateDir(), 'search.db'));
35
36
  search.start();
37
+ /**
38
+ * The MCP tools, bound to this relay over loopback.
39
+ *
40
+ * A self-request looks odd and is deliberate: the alternative is carving every route
41
+ * handler out of the router below so the tools could call them directly, which buys
42
+ * a sub-millisecond hop and costs the guarantee that matters — that a tool behaves
43
+ * identically over `POST /mcp` and over `conductor-remote mcp`'s stdio. One code
44
+ * path, one set of budgets, one place a route's semantics live.
45
+ */
46
+ const mcpTools = createTools(async (route, opts = {}) => {
47
+ // 0.0.0.0 binds every interface, so loopback still reaches us; a pinned RELAY_HOST
48
+ // is the address we actually answer on.
49
+ const host = !cfg.host || cfg.host === '0.0.0.0' || cfg.host === '::' ? '127.0.0.1' : cfg.host;
50
+ const timeoutMs = opts.timeoutMs ?? READ_TIMEOUT_MS;
51
+ const res = await fetch(`http://${host}:${cfg.port}${route}`, {
52
+ method: opts.method ?? 'GET',
53
+ signal: AbortSignal.timeout(timeoutMs),
54
+ headers: {
55
+ authorization: `Bearer ${cfg.token}`,
56
+ 'content-type': 'application/json',
57
+ 'x-relay-client': 'mcp',
58
+ 'x-client-timeout-ms': String(timeoutMs)
59
+ },
60
+ body: opts.body === undefined ? undefined : JSON.stringify(opts.body)
61
+ });
62
+ const payload = (await res.json().catch(() => ({})));
63
+ if (!res.ok) {
64
+ const busy = res.status === 503 ? ' (Conductor’s UI is busy — retry shortly)' : '';
65
+ throw new Error(`${payload.error || `HTTP ${res.status}`}${busy}`);
66
+ }
67
+ return payload;
68
+ });
36
69
  // A windowless Conductor that ignores reopen *and* a Dock click can only be fixed
37
70
  // by restarting it — and quitting takes any agent mid-turn down with it. So the
38
71
  // write path may only do that while nothing is working, which is a DB fact, not
@@ -226,13 +259,14 @@ const firstPrompts = new FirstPromptQueue(path.join(stateDir(), 'first-prompts.j
226
259
  alreadySent: !!session?.last_user_message_at
227
260
  };
228
261
  },
229
- send: async (workspaceId, sessionId, text) => {
262
+ // The queue fires on its own schedule, so it must never make a human tap wait.
263
+ send: (workspaceId, sessionId, text) => withUiPriority('background', async () => {
230
264
  const ws = reads.getWorkspace(workspaceId);
231
265
  if (!ws)
232
266
  return { ok: false, error: 'the workspace is gone' };
233
267
  const result = await deliverPrompt(ws, sessionId, text);
234
268
  return { ok: result.ok, error: result.error, blocked: lockBlocked(result.error) };
235
- },
269
+ }),
236
270
  // A locked Mac holds first prompts whole — no attempts spent, no aging — instead
237
271
  // of burning all three sends into a lock screen nobody is there to see.
238
272
  gate: async () => (await screenLocked()) !== true
@@ -268,7 +302,8 @@ const PARKED_ERROR = 'The Mac is locked — the relay parked the prompt and will
268
302
  */
269
303
  const parkedPrompts = new ParkedPromptQueue(path.join(stateDir(), 'parked-prompts.json'), {
270
304
  locked: screenLocked,
271
- deliver: async (entry) => {
305
+ // Delivers on unlock, on its own schedule — background, like the first-prompt queue.
306
+ deliver: entry => withUiPriority('background', async () => {
272
307
  const ws = reads.getWorkspace(entry.workspaceId);
273
308
  if (!ws)
274
309
  return { ok: false, error: 'the workspace is gone' };
@@ -284,7 +319,7 @@ const parkedPrompts = new ParkedPromptQueue(path.join(stateDir(), 'parked-prompt
284
319
  }
285
320
  const result = await deliverPrompt(ws, entry.sessionId, entry.text);
286
321
  return { ok: result.ok, error: result.error, blocked: lockBlocked(result.error) };
287
- },
322
+ }),
288
323
  notify: (entry, error) => {
289
324
  const ws = reads.getWorkspace(entry.workspaceId);
290
325
  const title = ws?.workspace_name ?? ws?.pr_title ?? ws?.branch ?? 'Conductor';
@@ -392,525 +427,607 @@ function serveStatic(_req, res, pathname) {
392
427
  res.end(data);
393
428
  });
394
429
  }
430
+ /**
431
+ * MCP over HTTP.
432
+ *
433
+ * Two guards beyond the token. **Origin is rejected when present and foreign**: a real
434
+ * MCP client sends none, and a browser cannot omit it — so this closes the DNS-rebinding
435
+ * hole the spec warns about without needing to know our own hostname behind Tailscale's
436
+ * TLS. And **the body is capped**, because this endpoint is reachable from the internet
437
+ * whenever EXPOSE=public and an unbounded JSON parse is the cheapest thing to abuse.
438
+ */
439
+ async function handleMcpHttp(req, res) {
440
+ if (req.method === 'GET' || req.method === 'DELETE') {
441
+ // No server-initiated messages and no session to end. 405 is the spec's own answer
442
+ // for a server that doesn't offer the stream.
443
+ res.writeHead(405, { allow: 'POST' }).end();
444
+ return;
445
+ }
446
+ if (req.method !== 'POST')
447
+ return void res.writeHead(405, { allow: 'POST' }).end();
448
+ const origin = req.headers.origin;
449
+ if (origin)
450
+ return void json(req, res, 403, { error: 'cross-origin requests are not accepted here' });
451
+ if (!authed(req))
452
+ return void json(req, res, 401, { error: 'unauthorized' });
453
+ let body;
454
+ try {
455
+ body = await readBody(req);
456
+ }
457
+ catch {
458
+ return void json(req, res, 400, { error: 'could not read request body' });
459
+ }
460
+ if (body.length > 1_000_000)
461
+ return void json(req, res, 413, { error: 'request too large' });
462
+ let parsed;
463
+ try {
464
+ parsed = JSON.parse(body || 'null');
465
+ }
466
+ catch {
467
+ res.writeHead(400, { 'content-type': 'application/json' });
468
+ return void res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }));
469
+ }
470
+ // Agents yield the UI lock to whoever is holding the phone, exactly as the stdio
471
+ // transport does via its `x-relay-client` header.
472
+ const answers = await withUiPriority('background', async () => {
473
+ const batch = Array.isArray(parsed) ? parsed : [parsed];
474
+ const settled = await Promise.all(batch.map(m => handleRpc(mcpTools, m)));
475
+ return settled.filter(m => m !== null);
476
+ });
477
+ // A payload of nothing but notifications takes no reply at all.
478
+ if (!answers.length)
479
+ return void res.writeHead(202).end();
480
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
481
+ res.end(JSON.stringify(Array.isArray(parsed) ? answers : answers[0]));
482
+ }
395
483
  const server = http.createServer(async (req, res) => {
396
484
  const url = new URL(req.url ?? '/', 'http://x');
397
485
  const { pathname } = url;
486
+ // POST /mcp — the MCP Streamable HTTP transport, for a client that can only reach a
487
+ // URL (an agent on another machine, or a hosted one). Same tools as
488
+ // `conductor-remote mcp`'s stdio, same token gate as /api/*, and — because this runs
489
+ // *inside* the relay — the same UI lock, with no second process to sit outside it.
490
+ //
491
+ // Deliberately minimal: this server never initiates a message, so there is no SSE
492
+ // stream to open and GET is answered 405, which the spec allows. It keeps no session
493
+ // either, so no `Mcp-Session-Id` is issued and every request stands alone.
494
+ if (pathname === '/mcp')
495
+ return handleMcpHttp(req, res);
398
496
  if (!pathname.startsWith('/api/'))
399
497
  return serveStatic(req, res, pathname);
400
498
  // Everything under /api requires the shared secret.
401
499
  if (!authed(req))
402
500
  return json(req, res, 401, { error: 'unauthorized' });
403
- try {
404
- // GET /api/state — workspace list with active-session status
405
- if (req.method === 'GET' && pathname === '/api/state') {
406
- const update = updateStatus();
407
- const workspaces = reads.listWorkspaces();
408
- attachPrStatus(workspaces); // colours pr_status from cache; refreshes stale entries in the background
409
- // An undelivered first prompt rides along with its workspace: the phone renders it
410
- // in that chat rather than tracking delivery itself (see src/firstprompt.ts).
411
- // Prompts parked for the lock screen ride the same way, one list per workspace,
412
- // each entry naming its chat (src/parked.ts).
413
- const parked = parkedPrompts.list();
414
- for (const ws of workspaces) {
415
- ws.pending_prompt = firstPrompts.get(ws.id);
416
- const mine = parked.filter(p => p.workspaceId === ws.id);
417
- if (mine.length)
418
- ws.parked_prompts = mine;
501
+ // Who is asking decides who waits for Conductor's window. An agent (src/mcp.ts sets
502
+ // this header) yields the UI lock to the person holding the phone — see writes.ts ▸
503
+ // uiTurn. Anything unlabelled is treated as the person, because the phone is the
504
+ // only caller that predates the header and mislabelling it would be the bad way round.
505
+ const priority = req.headers['x-relay-client'] === 'mcp' ? 'background' : 'interactive';
506
+ return withUiPriority(priority, async () => {
507
+ try {
508
+ // GET /api/state — workspace list with active-session status
509
+ if (req.method === 'GET' && pathname === '/api/state') {
510
+ const update = updateStatus();
511
+ const workspaces = reads.listWorkspaces();
512
+ attachPrStatus(workspaces); // colours pr_status from cache; refreshes stale entries in the background
513
+ // An undelivered first prompt rides along with its workspace: the phone renders it
514
+ // in that chat rather than tracking delivery itself (see src/firstprompt.ts).
515
+ // Prompts parked for the lock screen ride the same way, one list per workspace,
516
+ // each entry naming its chat (src/parked.ts).
517
+ const parked = parkedPrompts.list();
518
+ for (const ws of workspaces) {
519
+ ws.pending_prompt = firstPrompts.get(ws.id);
520
+ const mine = parked.filter(p => p.workspaceId === ws.id);
521
+ if (mine.length)
522
+ ws.parked_prompts = mine;
523
+ }
524
+ return json(req, res, 200, {
525
+ workspaces,
526
+ actuator: await describeActuator(actuator),
527
+ version: update.current,
528
+ update
529
+ });
419
530
  }
420
- return json(req, res, 200, {
421
- workspaces,
422
- actuator: await describeActuator(actuator),
423
- version: update.current,
424
- update
425
- });
426
- }
427
- // GET /api/search?q= — find a workspace by its name or by what was said in its chats.
428
- //
429
- // Two sources, merged. `findWorkspacesByName` matches the workspace's own identity
430
- // and wins ties, because someone who types a name wants that workspace and not the
431
- // twelve chats that mention it. The transcript index answers the harder question —
432
- // "which workspace did I do this in" — and is the only one that can, since the
433
- // words you remember are usually the agent's, not the branch's.
434
- //
435
- // Both reach archived workspaces. That is the point: 1,846 of the 1,886 here are
436
- // archived, so a search limited to the live sidebar would miss almost everything.
437
- if (req.method === 'GET' && pathname === '/api/search') {
438
- const q = url.searchParams.get('q') ?? '';
439
- // 12, not 50: an OR query over common words ("add", "remove") has a long weak tail,
440
- // and past the first screenful nobody scrolls — they retype instead.
441
- const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? 12) || 12));
442
- const index = search.status();
443
- const tokens = queryTokens(q);
444
- if (!tokens.length)
445
- return json(req, res, 200, { query: q, results: [], index });
446
- const hits = search.search(q);
447
- const targets = reads.searchTargets([...new Set(hits.map(h => h.sessionId))]);
448
- const fromChats = foldHits(hits, sid => targets.get(sid)?.workspace ?? null);
449
- const remaining = new Map(fromChats.map(r => [r.workspace.id, r]));
450
- const merged = [];
451
- for (const workspace of reads.findWorkspacesByName(tokens, limit)) {
452
- const evidence = remaining.get(workspace.id);
453
- remaining.delete(workspace.id);
454
- // Keep the chat evidence when there is any: the snippet is what tells you this
455
- // is the right "fix-lamp-thing" out of three with similar names.
456
- merged.push(evidence
457
- ? { ...evidence, byName: true }
458
- : { workspace, sessionId: null, hits: 0, score: 0, at: null, snippets: [], byName: true });
531
+ // GET /api/search?q= — find a workspace by its name or by what was said in its chats.
532
+ //
533
+ // Two sources, merged. `findWorkspacesByName` matches the workspace's own identity
534
+ // and wins ties, because someone who types a name wants that workspace and not the
535
+ // twelve chats that mention it. The transcript index answers the harder question —
536
+ // "which workspace did I do this in" — and is the only one that can, since the
537
+ // words you remember are usually the agent's, not the branch's.
538
+ //
539
+ // Both reach archived workspaces. That is the point: 1,846 of the 1,886 here are
540
+ // archived, so a search limited to the live sidebar would miss almost everything.
541
+ if (req.method === 'GET' && pathname === '/api/search') {
542
+ const q = url.searchParams.get('q') ?? '';
543
+ // 12, not 50: an OR query over common words ("add", "remove") has a long weak tail,
544
+ // and past the first screenful nobody scrolls — they retype instead.
545
+ const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? 12) || 12));
546
+ const index = search.status();
547
+ const tokens = queryTokens(q);
548
+ if (!tokens.length)
549
+ return json(req, res, 200, { query: q, results: [], index });
550
+ const hits = search.search(q);
551
+ const targets = reads.searchTargets([...new Set(hits.map(h => h.sessionId))]);
552
+ const fromChats = foldHits(hits, sid => targets.get(sid)?.workspace ?? null);
553
+ const remaining = new Map(fromChats.map(r => [r.workspace.id, r]));
554
+ const merged = [];
555
+ for (const workspace of reads.findWorkspacesByName(tokens, limit)) {
556
+ const evidence = remaining.get(workspace.id);
557
+ remaining.delete(workspace.id);
558
+ // Keep the chat evidence when there is any: the snippet is what tells you this
559
+ // is the right "fix-lamp-thing" out of three with similar names.
560
+ merged.push(evidence
561
+ ? { ...evidence, byName: true }
562
+ : { workspace, sessionId: null, hits: 0, score: 0, at: null, snippets: [], byName: true });
563
+ }
564
+ merged.push(...remaining.values());
565
+ return json(req, res, 200, {
566
+ query: q,
567
+ index,
568
+ results: merged.slice(0, limit).map(r => ({
569
+ ...r,
570
+ sessionTitle: r.sessionId ? (targets.get(r.sessionId)?.sessionTitle ?? null) : null
571
+ }))
572
+ });
459
573
  }
460
- merged.push(...remaining.values());
461
- return json(req, res, 200, {
462
- query: q,
463
- index,
464
- results: merged.slice(0, limit).map(r => ({
465
- ...r,
466
- sessionTitle: r.sessionId ? (targets.get(r.sessionId)?.sessionTitle ?? null) : null
467
- }))
468
- });
469
- }
470
- // GET /api/repos — repos a new workspace can be created in
471
- if (req.method === 'GET' && pathname === '/api/repos') {
472
- return json(req, res, 200, { repos: reads.listRepos() });
473
- }
474
- // GET /api/settings — relay preferences plus what the phone needs to edit them:
475
- // the SSIDs this Mac already holds credentials for, so the picker offers a choice
476
- // instead of asking someone to type a network name from memory on a phone keyboard.
477
- // `ssid` is best-effort and often null (macOS gates it behind Location Services).
478
- if (req.method === 'GET' && pathname === '/api/settings') {
479
- // Four subprocesses, all concurrent: this is the one route that shells out more
480
- // than once, and serialising them would put the phone's polls behind the sum.
481
- const [known, current, autoJoinHotspot, nosleep] = await Promise.all([
482
- preferredNetworks(),
483
- currentSsid(),
484
- // macOS's own Auto-join Hotspot setting. On "Never" the Mac won't reach for
485
- // your phone unprompted, which no amount of relay code can substitute for.
486
- autoJoinHotspotMode(),
487
- nosleepState()
488
- ]);
489
- return json(req, res, 200, {
490
- settings: readSettings(),
491
- wifi: {
492
- current,
493
- known,
494
- // A guess from the name, never a fact — see wifi.ts. It only sorts the picker.
495
- likelyHotspots: known.filter(looksLikeHotspot),
496
- autoJoinHotspot
497
- },
498
- nosleep: { ...nosleep, maxSeconds: NOSLEEP_MAX_SECONDS }
499
- });
500
- }
501
- // PATCH /api/settings { fallbackSsids?, autoRejoin? } — merge and persist.
502
- if (req.method === 'PATCH' && pathname === '/api/settings') {
503
- const body = JSON.parse((await readBody(req)) || '{}');
504
- const patch = {};
505
- if (Array.isArray(body.fallbackSsids))
506
- patch.fallbackSsids = body.fallbackSsids;
507
- if (typeof body.autoRejoin === 'boolean')
508
- patch.autoRejoin = body.autoRejoin;
509
- if (Object.keys(patch).length === 0)
510
- return json(req, res, 400, { error: 'nothing to change' });
511
- return json(req, res, 200, { settings: writeSettings(patch) });
512
- }
513
- // GET /api/nosleep — is the Mac being held awake, and can this relay do it at all
514
- if (req.method === 'GET' && pathname === '/api/nosleep') {
515
- return json(req, res, 200, { ...(await nosleepState()), maxSeconds: NOSLEEP_MAX_SECONDS });
516
- }
517
- // POST /api/nosleep { seconds } — hold this Mac awake, lid closed, for a bounded window.
518
- // Only works once `conductor-remote nosleep setup` has installed the scoped sudoers
519
- // rule; without it there is no way for a TTY-less daemon to reach root, and the
520
- // response says so rather than failing vaguely.
521
- if (req.method === 'POST' && pathname === '/api/nosleep') {
522
- const body = JSON.parse((await readBody(req)) || '{}');
523
- const seconds = Number(body.seconds);
524
- // Whole seconds, not just "> 0": the helper reads 0 as "until killed", and 0.4
525
- // truncates to 0 — an unbounded window from a request that looked bounded.
526
- if (!Number.isInteger(seconds) || seconds < 1)
527
- return json(req, res, 400, { error: 'need a whole number of seconds >= 1' });
528
- const result = await armNoSleep(seconds);
529
- return json(req, res, result.ok ? 200 : result.state.available ? 502 : 409, result);
530
- }
531
- // DELETE /api/nosleep — let it sleep again now, rather than at the window's end
532
- if (req.method === 'DELETE' && pathname === '/api/nosleep') {
533
- const result = await disarmNoSleep();
534
- return json(req, res, result.ok ? 200 : result.state.available ? 502 : 409, result);
535
- }
536
- // GET /api/logs?file=&limit= — the relay's own log, so a phone can diagnose a failed send
537
- // without reaching the Mac. Default is this process's captured console (ordered, timestamped);
538
- // `file` tails the daemon's stdout/stderr on disk, which is the only place the *previous*
539
- // process's crash survives. Everything is redacted: the startup banner prints the token.
540
- if (req.method === 'GET' && pathname === '/api/logs') {
541
- const file = url.searchParams.get('file');
542
- if (file && !LOG_FILE_NAMES.includes(file)) {
543
- return json(req, res, 404, { error: `unknown log file ${file}`, files: LOG_FILE_NAMES });
574
+ // GET /api/repos — repos a new workspace can be created in
575
+ if (req.method === 'GET' && pathname === '/api/repos') {
576
+ return json(req, res, 200, { repos: reads.listRepos() });
577
+ }
578
+ // GET /api/settings — relay preferences plus what the phone needs to edit them:
579
+ // the SSIDs this Mac already holds credentials for, so the picker offers a choice
580
+ // instead of asking someone to type a network name from memory on a phone keyboard.
581
+ // `ssid` is best-effort and often null (macOS gates it behind Location Services).
582
+ if (req.method === 'GET' && pathname === '/api/settings') {
583
+ // Four subprocesses, all concurrent: this is the one route that shells out more
584
+ // than once, and serialising them would put the phone's polls behind the sum.
585
+ const [known, current, autoJoinHotspot, nosleep] = await Promise.all([
586
+ preferredNetworks(),
587
+ currentSsid(),
588
+ // macOS's own Auto-join Hotspot setting. On "Never" the Mac won't reach for
589
+ // your phone unprompted, which no amount of relay code can substitute for.
590
+ autoJoinHotspotMode(),
591
+ nosleepState()
592
+ ]);
593
+ return json(req, res, 200, {
594
+ settings: readSettings(),
595
+ wifi: {
596
+ current,
597
+ known,
598
+ // A guess from the name, never a fact — see wifi.ts. It only sorts the picker.
599
+ likelyHotspots: known.filter(looksLikeHotspot),
600
+ autoJoinHotspot
601
+ },
602
+ nosleep: { ...nosleep, maxSeconds: NOSLEEP_MAX_SECONDS }
603
+ });
544
604
  }
545
- const asked = Number(url.searchParams.get('limit') ?? 300);
546
- const limit = Number.isFinite(asked) ? Math.min(2000, Math.max(1, Math.trunc(asked))) : 300;
547
- let entries;
548
- try {
549
- entries = file ? tailLogFile(file, limit) : recentLogs(limit);
605
+ // PATCH /api/settings { fallbackSsids?, autoRejoin? } — merge and persist.
606
+ if (req.method === 'PATCH' && pathname === '/api/settings') {
607
+ const body = JSON.parse((await readBody(req)) || '{}');
608
+ const patch = {};
609
+ if (Array.isArray(body.fallbackSsids))
610
+ patch.fallbackSsids = body.fallbackSsids;
611
+ if (typeof body.autoRejoin === 'boolean')
612
+ patch.autoRejoin = body.autoRejoin;
613
+ if (Object.keys(patch).length === 0)
614
+ return json(req, res, 400, { error: 'nothing to change' });
615
+ return json(req, res, 200, { settings: writeSettings(patch) });
550
616
  }
551
- catch (err) {
552
- // The file only exists once the LaunchAgent has run; say so instead of a bare 500.
553
- return json(req, res, 404, { error: `can’t read ${file}: ${err instanceof Error ? err.message : err}` });
617
+ // GET /api/nosleep — is the Mac being held awake, and can this relay do it at all
618
+ if (req.method === 'GET' && pathname === '/api/nosleep') {
619
+ return json(req, res, 200, { ...(await nosleepState()), maxSeconds: NOSLEEP_MAX_SECONDS });
554
620
  }
555
- return json(req, res, 200, {
556
- source: file ?? 'live',
557
- // False → the files below are some *other* (daemon) process's output, not this relay's.
558
- managed: isManaged(),
559
- startedAt: processStartedAt(),
560
- now: Date.now(),
561
- files: logFiles(),
562
- entries: entries.map(e => ({ ...e, text: redactSecrets(e.text, cfg.token) }))
563
- });
564
- }
565
- // GET /api/push — the VAPID public key the phone subscribes with, plus who's already subscribed
566
- if (req.method === 'GET' && pathname === '/api/push') {
567
- return json(req, res, 200, pushConfig());
568
- }
569
- // POST /api/push/subscribe { subscription, label? } — register (or refresh) this device.
570
- // Idempotent by endpoint: the app re-sends on every load, which is what heals a relay that
571
- // lost its store, or a subscription the browser silently renewed.
572
- if (req.method === 'POST' && pathname === '/api/push/subscribe') {
573
- const body = JSON.parse((await readBody(req)) || '{}');
574
- const sub = body.subscription;
575
- if (!sub?.endpoint || !sub.keys?.p256dh || !sub.keys.auth) {
576
- return json(req, res, 400, { error: 'need a subscription with endpoint and keys' });
621
+ // POST /api/nosleep { seconds } — hold this Mac awake, lid closed, for a bounded window.
622
+ // Only works once `conductor-remote nosleep setup` has installed the scoped sudoers
623
+ // rule; without it there is no way for a TTY-less daemon to reach root, and the
624
+ // response says so rather than failing vaguely.
625
+ if (req.method === 'POST' && pathname === '/api/nosleep') {
626
+ const body = JSON.parse((await readBody(req)) || '{}');
627
+ const seconds = Number(body.seconds);
628
+ // Whole seconds, not just "> 0": the helper reads 0 as "until killed", and 0.4
629
+ // truncates to 0 — an unbounded window from a request that looked bounded.
630
+ if (!Number.isInteger(seconds) || seconds < 1)
631
+ return json(req, res, 400, { error: 'need a whole number of seconds >= 1' });
632
+ const result = await armNoSleep(seconds);
633
+ return json(req, res, result.ok ? 200 : result.state.available ? 502 : 409, result);
577
634
  }
578
- // An endpoint is a URL we will POST to — never accept a non-HTTPS one.
579
- if (!/^https:\/\//i.test(sub.endpoint))
580
- return json(req, res, 400, { error: 'endpoint must be https' });
581
- const registered = subscribeDevice({ endpoint: sub.endpoint, keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth } }, (body.label ?? '').slice(0, 64));
582
- return json(req, res, 200, { ok: true, ...registered });
583
- }
584
- // POST /api/push/unsubscribe { endpoint } — the phone turned notifications off
585
- if (req.method === 'POST' && pathname === '/api/push/unsubscribe') {
586
- const body = JSON.parse((await readBody(req)) || '{}');
587
- if (!body.endpoint)
588
- return json(req, res, 400, { error: 'need the endpoint' });
589
- return json(req, res, 200, { ok: unsubscribeDevice(body.endpoint), devices: pushConfig().devices });
590
- }
591
- // POST /api/push/test { id } — push to one device, so "is this actually wired up?" has an answer
592
- if (req.method === 'POST' && pathname === '/api/push/test') {
593
- const body = JSON.parse((await readBody(req)) || '{}');
594
- if (!body.id)
595
- return json(req, res, 400, { error: 'need the device id' });
596
- const result = await notifyDevice(body.id, {
597
- title: 'Conductor Remote',
598
- body: 'Notifications are working. You’ll get one when an agent finishes.',
599
- tag: 'test',
600
- url: '/',
601
- kind: 'test',
602
- ts: Date.now()
603
- });
604
- return json(req, res, result.ok ? 200 : 502, result);
605
- }
606
- // POST /api/workspaces { repo, prompt, send? } — create a workspace via Conductor's deep link
607
- if (req.method === 'POST' && pathname === '/api/workspaces') {
608
- const body = JSON.parse((await readBody(req)) || '{}');
609
- // The prompt is optional — a bare `path=` opens an empty workspace, like
610
- // Conductor's own New workspace — but *something* has to say where it goes.
611
- const prompt = (body.prompt ?? '').trim();
612
- if (!prompt && !body.repo)
613
- return json(req, res, 400, { error: 'need a repo or a prompt' });
614
- // Resolve the repo to a real path: an unmatched `path` would silently land
615
- // the workspace in whichever repo Conductor happens to list first.
616
- const repo = body.repo ? reads.listRepos().find(r => r.name === body.repo) : undefined;
617
- if (body.repo && !repo)
618
- return json(req, res, 404, { error: `unknown repo ${body.repo}` });
619
- if (repo && !repo.root_path)
620
- return json(req, res, 409, { error: `${repo.name} has no checkout path` });
621
- const before = new Set(reads.listWorkspaces().map(w => w.id));
622
- const result = await createWorkspace(prompt, repo?.root_path ?? null);
623
- if (!result.ok)
624
- return json(req, res, 502, result);
625
- // The deep link is fire-and-forget, so the new row is the only proof it worked.
626
- // Creating a worktree takes a beat longer than opening a chat does.
627
- let created;
628
- for (let attempt = 0; attempt < 40 && !created; attempt++) {
629
- await sleep(500);
630
- created = reads.listWorkspaces().find(w => !before.has(w.id));
635
+ // DELETE /api/nosleep — let it sleep again now, rather than at the window's end
636
+ if (req.method === 'DELETE' && pathname === '/api/nosleep') {
637
+ const result = await disarmNoSleep();
638
+ return json(req, res, result.ok ? 200 : result.state.available ? 502 : 409, result);
631
639
  }
632
- if (!created) {
633
- return json(req, res, 502, {
634
- ok: false,
635
- strategy: result.strategy,
636
- error: 'Conductor didn’t create a workspace — check it’s running and not showing a dialog.'
640
+ // GET /api/logs?file=&limit= — the relay's own log, so a phone can diagnose a failed send
641
+ // without reaching the Mac. Default is this process's captured console (ordered, timestamped);
642
+ // `file` tails the daemon's stdout/stderr on disk, which is the only place the *previous*
643
+ // process's crash survives. Everything is redacted: the startup banner prints the token.
644
+ if (req.method === 'GET' && pathname === '/api/logs') {
645
+ const file = url.searchParams.get('file');
646
+ if (file && !LOG_FILE_NAMES.includes(file)) {
647
+ return json(req, res, 404, { error: `unknown log file ${file}`, files: LOG_FILE_NAMES });
648
+ }
649
+ const asked = Number(url.searchParams.get('limit') ?? 300);
650
+ const limit = Number.isFinite(asked) ? Math.min(2000, Math.max(1, Math.trunc(asked))) : 300;
651
+ let entries;
652
+ try {
653
+ entries = file ? tailLogFile(file, limit) : recentLogs(limit);
654
+ }
655
+ catch (err) {
656
+ // The file only exists once the LaunchAgent has run; say so instead of a bare 500.
657
+ return json(req, res, 404, { error: `can’t read ${file}: ${err instanceof Error ? err.message : err}` });
658
+ }
659
+ return json(req, res, 200, {
660
+ source: file ?? 'live',
661
+ // False → the files below are some *other* (daemon) process's output, not this relay's.
662
+ managed: isManaged(),
663
+ startedAt: processStartedAt(),
664
+ now: Date.now(),
665
+ files: logFiles(),
666
+ entries: entries.map(e => ({ ...e, text: redactSecrets(e.text, cfg.token) }))
637
667
  });
638
668
  }
639
- // Return as soon as the row exists (~2s) — waiting for delivery would block the
640
- // request through Conductor's whole setup, measured at 30s+ on a real repo and
641
- // past any budget a phone should hold a request open for. The queue delivers on
642
- // its own schedule and the phone watches it in /api/state; `send:true` opts API
643
- // callers into waiting.
644
- // Whatever happens, the prompt is already pre-filled in Conductor's composer.
645
- const settled = prompt ? firstPrompts.enqueue(created.id, prompt) : null;
646
- const failed = settled && body.send === true ? await settled : null;
647
- settled?.catch(() => undefined); // fire-and-forget: it reports failure, it never rejects
648
- return json(req, res, 200, {
649
- ok: true,
650
- workspaceId: created.id,
651
- workspace: reads.getWorkspace(created.id) ?? created,
652
- pendingPrompt: prompt || undefined,
653
- sent: body.send === true ? !failed : false,
654
- warning: failed?.error && `Workspace created; the prompt is pre-filled but wasn’t sent (${failed.error}).`
655
- });
656
- }
657
- // GET /api/repos/:name/icon — the repo's resolved sidebar icon (see src/icons.ts)
658
- let m = pathname.match(/^\/api\/repos\/([^/]+)\/icon$/);
659
- if (req.method === 'GET' && m) {
660
- const icon = reads.resolveRepoIcon(decodeURIComponent(m[1]));
661
- if (!icon)
662
- return json(req, res, 404, { error: 'no icon' });
663
- return void fs.readFile(icon.path, (err, data) => {
664
- if (err)
665
- return void json(req, res, 404, { error: 'no icon' });
666
- // Cache briefly on the phone; the resolver itself refreshes within ~30s of an icon change.
667
- res.writeHead(200, { 'content-type': icon.contentType, 'cache-control': 'public, max-age=300' });
668
- res.end(data);
669
- });
670
- }
671
- // GET /api/workspaces/:id/sessions
672
- m = pathname.match(/^\/api\/workspaces\/([^/]+)\/sessions$/);
673
- if (req.method === 'GET' && m) {
674
- return json(req, res, 200, { sessions: reads.listSessions(decodeURIComponent(m[1])) });
675
- }
676
- // POST /api/workspaces/:id/sessions — open a new chat (Cmd+T) in the workspace
677
- if (req.method === 'POST' && m) {
678
- const workspaceId = decodeURIComponent(m[1]);
679
- const ws = reads.getWorkspace(workspaceId);
680
- if (!ws)
681
- return json(req, res, 404, { error: 'workspace not found' });
682
- const before = new Set(reads.listSessions(workspaceId).map(s => s.id));
683
- const result = await newChat(ws);
684
- if (!result.ok)
685
- return json(req, res, 502, result);
686
- // The new session lands in the DB a beat after Cmd+T — poll for the fresh id.
687
- let sessionId = null;
688
- for (let i = 0; i < 12 && !sessionId; i++) {
689
- await new Promise(r => setTimeout(r, 500));
690
- sessionId = reads.listSessions(workspaceId).find(s => !before.has(s.id))?.id ?? null;
669
+ // GET /api/push — the VAPID public key the phone subscribes with, plus who's already subscribed
670
+ if (req.method === 'GET' && pathname === '/api/push') {
671
+ return json(req, res, 200, pushConfig());
691
672
  }
692
- return json(req, res, 200, { ok: true, sessionId });
693
- }
694
- // GET /api/workspaces/:id/diff
695
- m = pathname.match(/^\/api\/workspaces\/([^/]+)\/diff$/);
696
- if (req.method === 'GET' && m) {
697
- const ws = reads.getWorkspace(decodeURIComponent(m[1]));
698
- if (!ws)
699
- return json(req, res, 404, { error: 'workspace not found' });
700
- if (!ws.worktree)
701
- return json(req, res, 409, { error: 'worktree path unresolved' });
702
- const diff = await workspaceDiff(ws.worktree, ws.baseBranch);
703
- return json(req, res, 200, diff);
704
- }
705
- // POST /api/workspaces/:id/merge — merge the workspace's open PR (mirrors Conductor's merge button)
706
- m = pathname.match(/^\/api\/workspaces\/([^/]+)\/merge$/);
707
- if (req.method === 'POST' && m) {
708
- const ws = reads.getWorkspace(decodeURIComponent(m[1]));
709
- if (!ws)
710
- return json(req, res, 404, { error: 'workspace not found' });
711
- const result = await mergePr(ws);
712
- return json(req, res, result.ok ? 200 : 409, result);
713
- }
714
- // POST /api/workspaces/:id/status { status } — move it between the sidebar's status groups.
715
- // Conductor derives that status from a PR it sometimes never links (a PR merged inside its
716
- // poll window is invisible to it afterwards), which strands finished work in "In progress"
717
- // with no way to correct it from a phone. This is that way.
718
- m = pathname.match(/^\/api\/workspaces\/([^/]+)\/status$/);
719
- if (req.method === 'POST' && m) {
720
- const workspaceId = decodeURIComponent(m[1]);
721
- const body = JSON.parse((await readBody(req)) || '{}');
722
- const status = body.status ?? '';
723
- if (!WORKSPACE_STATUS_LABELS[status]) {
724
- const allowed = Object.keys(WORKSPACE_STATUS_LABELS).join(', ');
725
- return json(req, res, 400, { error: `status must be one of ${allowed}` });
673
+ // POST /api/push/subscribe { subscription, label? } — register (or refresh) this device.
674
+ // Idempotent by endpoint: the app re-sends on every load, which is what heals a relay that
675
+ // lost its store, or a subscription the browser silently renewed.
676
+ if (req.method === 'POST' && pathname === '/api/push/subscribe') {
677
+ const body = JSON.parse((await readBody(req)) || '{}');
678
+ const sub = body.subscription;
679
+ if (!sub?.endpoint || !sub.keys?.p256dh || !sub.keys.auth) {
680
+ return json(req, res, 400, { error: 'need a subscription with endpoint and keys' });
681
+ }
682
+ // An endpoint is a URL we will POST to — never accept a non-HTTPS one.
683
+ if (!/^https:\/\//i.test(sub.endpoint))
684
+ return json(req, res, 400, { error: 'endpoint must be https' });
685
+ const registered = subscribeDevice({ endpoint: sub.endpoint, keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth } }, (body.label ?? '').slice(0, 64));
686
+ return json(req, res, 200, { ok: true, ...registered });
726
687
  }
727
- const ws = reads.getWorkspace(workspaceId);
728
- if (!ws)
729
- return json(req, res, 404, { error: 'workspace not found' });
730
- const result = await setWorkspaceStatus(ws, status);
731
- if (!result.ok)
732
- return json(req, res, 502, result);
733
- // The menu press lands in the DB a beat later. Confirm rather than assume —
734
- // and if Conductor wrote something else, say what, instead of "didn't work".
735
- let observed = ws.manual_status ?? '';
736
- for (let i = 0; i < 10 && observed !== status; i++) {
737
- await new Promise(r => setTimeout(r, 300));
738
- observed = reads.getWorkspace(workspaceId)?.manual_status ?? '';
688
+ // POST /api/push/unsubscribe { endpoint } — the phone turned notifications off
689
+ if (req.method === 'POST' && pathname === '/api/push/unsubscribe') {
690
+ const body = JSON.parse((await readBody(req)) || '{}');
691
+ if (!body.endpoint)
692
+ return json(req, res, 400, { error: 'need the endpoint' });
693
+ return json(req, res, 200, { ok: unsubscribeDevice(body.endpoint), devices: pushConfig().devices });
739
694
  }
740
- if (observed !== status) {
741
- return json(req, res, 502, {
742
- ok: false,
743
- strategy: result.strategy,
744
- error: observed
745
- ? `Conductor recorded the status as “${observed}”, not “${status}”.`
746
- : 'Conductor didn’t record the change — it may have been asleep. Try again.'
695
+ // POST /api/push/test { id } — push to one device, so "is this actually wired up?" has an answer
696
+ if (req.method === 'POST' && pathname === '/api/push/test') {
697
+ const body = JSON.parse((await readBody(req)) || '{}');
698
+ if (!body.id)
699
+ return json(req, res, 400, { error: 'need the device id' });
700
+ const result = await notifyDevice(body.id, {
701
+ title: 'Conductor Remote',
702
+ body: 'Notifications are working. You’ll get one when an agent finishes.',
703
+ tag: 'test',
704
+ url: '/',
705
+ kind: 'test',
706
+ ts: Date.now()
747
707
  });
708
+ return json(req, res, result.ok ? 200 : 502, result);
748
709
  }
749
- return json(req, res, 200, { ok: true, workspace: reads.getWorkspace(workspaceId) });
750
- }
751
- // GET /api/sessions/:id/messages?after=<rowid>
752
- m = pathname.match(/^\/api\/sessions\/([^/]+)\/messages$/);
753
- if (req.method === 'GET' && m) {
754
- const after = Number(url.searchParams.get('after') ?? 0);
755
- return json(req, res, 200, reads.getMessages(decodeURIComponent(m[1]), Number.isFinite(after) ? after : 0));
756
- }
757
- // GET /api/sessions/:id/models?workspaceId= — labels from Conductor's live picker
758
- m = pathname.match(/^\/api\/sessions\/([^/]+)\/models$/);
759
- if (req.method === 'GET' && m) {
760
- const sessionId = decodeURIComponent(m[1]);
761
- const ws = reads.getWorkspace(url.searchParams.get('workspaceId') ?? '');
762
- if (!ws)
763
- return json(req, res, 404, { error: 'workspace for session not found' });
764
- const located = locateChat(ws, sessionId);
765
- if ('error' in located)
766
- return json(req, res, 409, { error: located.error });
767
- const result = await listAgentModels({ workspace: ws, sessionId, tab: located.tab });
768
- return json(req, res, result.ok ? 200 : 502, result);
769
- }
770
- // POST /api/sessions/:id/agent { effort?, plan?, fast?, model? }
771
- // Drives the composer's own model/effort/plan/fast controls for one chat.
772
- m = pathname.match(/^\/api\/sessions\/([^/]+)\/agent$/);
773
- if (req.method === 'POST' && m) {
774
- const sessionId = decodeURIComponent(m[1]);
775
- const body = JSON.parse((await readBody(req)) || '{}');
776
- if (body.effort && !EFFORT_LABELS[body.effort]) {
777
- return json(req, res, 400, { error: `effort must be one of ${Object.keys(EFFORT_LABELS).join(', ')}` });
710
+ // POST /api/workspaces { repo, prompt, send? } — create a workspace via Conductor's deep link
711
+ if (req.method === 'POST' && pathname === '/api/workspaces') {
712
+ const body = JSON.parse((await readBody(req)) || '{}');
713
+ // The prompt is optional — a bare `path=` opens an empty workspace, like
714
+ // Conductor's own New workspace — but *something* has to say where it goes.
715
+ const prompt = (body.prompt ?? '').trim();
716
+ if (!prompt && !body.repo)
717
+ return json(req, res, 400, { error: 'need a repo or a prompt' });
718
+ // Resolve the repo to a real path: an unmatched `path` would silently land
719
+ // the workspace in whichever repo Conductor happens to list first.
720
+ const repo = body.repo ? reads.listRepos().find(r => r.name === body.repo) : undefined;
721
+ if (body.repo && !repo)
722
+ return json(req, res, 404, { error: `unknown repo ${body.repo}` });
723
+ if (repo && !repo.root_path)
724
+ return json(req, res, 409, { error: `${repo.name} has no checkout path` });
725
+ const before = new Set(reads.listWorkspaces().map(w => w.id));
726
+ const result = await createWorkspace(prompt, repo?.root_path ?? null);
727
+ if (!result.ok)
728
+ return json(req, res, 502, result);
729
+ // The deep link is fire-and-forget, so the new row is the only proof it worked.
730
+ // Creating a worktree takes a beat longer than opening a chat does.
731
+ let created;
732
+ for (let attempt = 0; attempt < 40 && !created; attempt++) {
733
+ await sleep(500);
734
+ created = reads.listWorkspaces().find(w => !before.has(w.id));
735
+ }
736
+ if (!created) {
737
+ return json(req, res, 502, {
738
+ ok: false,
739
+ strategy: result.strategy,
740
+ error: 'Conductor didn’t create a workspace — check it’s running and not showing a dialog.'
741
+ });
742
+ }
743
+ // Return as soon as the row exists (~2s) — waiting for delivery would block the
744
+ // request through Conductor's whole setup, measured at 30s+ on a real repo and
745
+ // past any budget a phone should hold a request open for. The queue delivers on
746
+ // its own schedule and the phone watches it in /api/state; `send:true` opts API
747
+ // callers into waiting.
748
+ // Whatever happens, the prompt is already pre-filled in Conductor's composer.
749
+ const settled = prompt ? firstPrompts.enqueue(created.id, prompt) : null;
750
+ const failed = settled && body.send === true ? await settled : null;
751
+ settled?.catch(() => undefined); // fire-and-forget: it reports failure, it never rejects
752
+ return json(req, res, 200, {
753
+ ok: true,
754
+ workspaceId: created.id,
755
+ workspace: reads.getWorkspace(created.id) ?? created,
756
+ pendingPrompt: prompt || undefined,
757
+ sent: body.send === true ? !failed : false,
758
+ warning: failed?.error && `Workspace created; the prompt is pre-filled but wasn’t sent (${failed.error}).`
759
+ });
778
760
  }
779
- const ws = body.workspaceId
780
- ? reads.getWorkspace(body.workspaceId)
781
- : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
782
- if (!ws)
783
- return json(req, res, 404, { error: 'workspace for session not found' });
784
- const applied = await applyAgentPatch(ws, sessionId, body);
785
- if (!applied.ok)
786
- return json(req, res, 502, { ok: false, strategy: actuator.name, error: applied.error });
787
- return json(req, res, 200, { ok: true, session: reads.listSessions(ws.id).find(s => s.id === sessionId) });
788
- }
789
- // POST /api/sessions/:id/stop — the desktop app's stop button, for one chat.
790
- m = pathname.match(/^\/api\/sessions\/([^/]+)\/stop$/);
791
- if (req.method === 'POST' && m) {
792
- const sessionId = decodeURIComponent(m[1]);
793
- const body = JSON.parse((await readBody(req)) || '{}');
794
- const ws = body.workspaceId
795
- ? reads.getWorkspace(body.workspaceId)
796
- : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
797
- if (!ws)
798
- return json(req, res, 404, { error: 'workspace for session not found' });
799
- const located = locateChat(ws, sessionId);
800
- if ('error' in located)
801
- return json(req, res, 409, { error: located.error });
802
- // Nothing running is a success, not an error: the phone shows Stop the moment it
803
- // sends (the optimistic hint) and a turn that ends on its own a beat before the tap
804
- // is the common case, not a mistake worth a red banner. It also keeps the one
805
- // keystroke this route presses off an idle chat entirely — Conductor's own
806
- // composer has no stop button to mis-tap there either.
807
- const before = reads.listSessions(ws.id).find(s => s.id === sessionId);
808
- if (before?.status !== 'working') {
809
- return json(req, res, 200, { ok: true, alreadyIdle: true, session: before });
761
+ // GET /api/repos/:name/icon — the repo's resolved sidebar icon (see src/icons.ts)
762
+ let m = pathname.match(/^\/api\/repos\/([^/]+)\/icon$/);
763
+ if (req.method === 'GET' && m) {
764
+ const icon = reads.resolveRepoIcon(decodeURIComponent(m[1]));
765
+ if (!icon)
766
+ return json(req, res, 404, { error: 'no icon' });
767
+ return void fs.readFile(icon.path, (err, data) => {
768
+ if (err)
769
+ return void json(req, res, 404, { error: 'no icon' });
770
+ // Cache briefly on the phone; the resolver itself refreshes within ~30s of an icon change.
771
+ res.writeHead(200, { 'content-type': icon.contentType, 'cache-control': 'public, max-age=300' });
772
+ res.end(data);
773
+ });
810
774
  }
811
- const result = await stopTurn({ workspace: ws, sessionId, tab: located.tab });
812
- if (!result.ok)
813
- return json(req, res, 502, result);
814
- // The DB is the receipt, exactly as it is for agent settings: the keystroke is
815
- // fire-and-forget, so what counts is `status` leaving `working`. Conductor writes
816
- // that a beat after it tears the turn down.
817
- let observed = before.status;
818
- for (let i = 0; i < 20 && observed === 'working'; i++) {
819
- await sleep(300);
820
- observed = reads.listSessions(ws.id).find(s => s.id === sessionId)?.status ?? observed;
775
+ // GET /api/workspaces/:id/sessions
776
+ m = pathname.match(/^\/api\/workspaces\/([^/]+)\/sessions$/);
777
+ if (req.method === 'GET' && m) {
778
+ return json(req, res, 200, { sessions: reads.listSessions(decodeURIComponent(m[1])) });
779
+ }
780
+ // POST /api/workspaces/:id/sessions — open a new chat (Cmd+T) in the workspace
781
+ if (req.method === 'POST' && m) {
782
+ const workspaceId = decodeURIComponent(m[1]);
783
+ const ws = reads.getWorkspace(workspaceId);
784
+ if (!ws)
785
+ return json(req, res, 404, { error: 'workspace not found' });
786
+ const before = new Set(reads.listSessions(workspaceId).map(s => s.id));
787
+ const result = await newChat(ws);
788
+ if (!result.ok)
789
+ return json(req, res, 502, result);
790
+ // The new session lands in the DB a beat after Cmd+T — poll for the fresh id.
791
+ let sessionId = null;
792
+ for (let i = 0; i < 12 && !sessionId; i++) {
793
+ await new Promise(r => setTimeout(r, 500));
794
+ sessionId = reads.listSessions(workspaceId).find(s => !before.has(s.id))?.id ?? null;
795
+ }
796
+ return json(req, res, 200, { ok: true, sessionId });
797
+ }
798
+ // GET /api/workspaces/:id/diff
799
+ m = pathname.match(/^\/api\/workspaces\/([^/]+)\/diff$/);
800
+ if (req.method === 'GET' && m) {
801
+ const ws = reads.getWorkspace(decodeURIComponent(m[1]));
802
+ if (!ws)
803
+ return json(req, res, 404, { error: 'workspace not found' });
804
+ if (!ws.worktree)
805
+ return json(req, res, 409, { error: 'worktree path unresolved' });
806
+ const diff = await workspaceDiff(ws.worktree, ws.baseBranch);
807
+ return json(req, res, 200, diff);
808
+ }
809
+ // POST /api/workspaces/:id/merge — merge the workspace's open PR (mirrors Conductor's merge button)
810
+ m = pathname.match(/^\/api\/workspaces\/([^/]+)\/merge$/);
811
+ if (req.method === 'POST' && m) {
812
+ const ws = reads.getWorkspace(decodeURIComponent(m[1]));
813
+ if (!ws)
814
+ return json(req, res, 404, { error: 'workspace not found' });
815
+ const result = await mergePr(ws);
816
+ return json(req, res, result.ok ? 200 : 409, result);
821
817
  }
822
- if (observed === 'working') {
823
- return json(req, res, 502, {
824
- ok: false,
818
+ // POST /api/workspaces/:id/status { status } — move it between the sidebar's status groups.
819
+ // Conductor derives that status from a PR it sometimes never links (a PR merged inside its
820
+ // poll window is invisible to it afterwards), which strands finished work in "In progress"
821
+ // with no way to correct it from a phone. This is that way.
822
+ m = pathname.match(/^\/api\/workspaces\/([^/]+)\/status$/);
823
+ if (req.method === 'POST' && m) {
824
+ const workspaceId = decodeURIComponent(m[1]);
825
+ const body = JSON.parse((await readBody(req)) || '{}');
826
+ const status = body.status ?? '';
827
+ if (!WORKSPACE_STATUS_LABELS[status]) {
828
+ const allowed = Object.keys(WORKSPACE_STATUS_LABELS).join(', ');
829
+ return json(req, res, 400, { error: `status must be one of ${allowed}` });
830
+ }
831
+ const ws = reads.getWorkspace(workspaceId);
832
+ if (!ws)
833
+ return json(req, res, 404, { error: 'workspace not found' });
834
+ const result = await setWorkspaceStatus(ws, status);
835
+ if (!result.ok)
836
+ return json(req, res, 502, result);
837
+ // The menu press lands in the DB a beat later. Confirm rather than assume —
838
+ // and if Conductor wrote something else, say what, instead of "didn't work".
839
+ let observed = ws.manual_status ?? '';
840
+ for (let i = 0; i < 10 && observed !== status; i++) {
841
+ await new Promise(r => setTimeout(r, 300));
842
+ observed = reads.getWorkspace(workspaceId)?.manual_status ?? '';
843
+ }
844
+ if (observed !== status) {
845
+ return json(req, res, 502, {
846
+ ok: false,
847
+ strategy: result.strategy,
848
+ error: observed
849
+ ? `Conductor recorded the status as “${observed}”, not “${status}”.`
850
+ : 'Conductor didn’t record the change — it may have been asleep. Try again.'
851
+ });
852
+ }
853
+ return json(req, res, 200, { ok: true, workspace: reads.getWorkspace(workspaceId) });
854
+ }
855
+ // GET /api/sessions/:id/messages?after=<rowid>
856
+ m = pathname.match(/^\/api\/sessions\/([^/]+)\/messages$/);
857
+ if (req.method === 'GET' && m) {
858
+ const after = Number(url.searchParams.get('after') ?? 0);
859
+ return json(req, res, 200, reads.getMessages(decodeURIComponent(m[1]), Number.isFinite(after) ? after : 0));
860
+ }
861
+ // GET /api/sessions/:id/models?workspaceId= — labels from Conductor's live picker
862
+ m = pathname.match(/^\/api\/sessions\/([^/]+)\/models$/);
863
+ if (req.method === 'GET' && m) {
864
+ const sessionId = decodeURIComponent(m[1]);
865
+ const ws = reads.getWorkspace(url.searchParams.get('workspaceId') ?? '');
866
+ if (!ws)
867
+ return json(req, res, 404, { error: 'workspace for session not found' });
868
+ const located = locateChat(ws, sessionId);
869
+ if ('error' in located)
870
+ return json(req, res, 409, { error: located.error });
871
+ const result = await listAgentModels({ workspace: ws, sessionId, tab: located.tab });
872
+ return json(req, res, result.ok ? 200 : 502, result);
873
+ }
874
+ // POST /api/sessions/:id/agent { effort?, plan?, fast?, model? }
875
+ // Drives the composer's own model/effort/plan/fast controls for one chat.
876
+ m = pathname.match(/^\/api\/sessions\/([^/]+)\/agent$/);
877
+ if (req.method === 'POST' && m) {
878
+ const sessionId = decodeURIComponent(m[1]);
879
+ const body = JSON.parse((await readBody(req)) || '{}');
880
+ if (body.effort && !EFFORT_LABELS[body.effort]) {
881
+ return json(req, res, 400, { error: `effort must be one of ${Object.keys(EFFORT_LABELS).join(', ')}` });
882
+ }
883
+ const ws = body.workspaceId
884
+ ? reads.getWorkspace(body.workspaceId)
885
+ : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
886
+ if (!ws)
887
+ return json(req, res, 404, { error: 'workspace for session not found' });
888
+ const applied = await applyAgentPatch(ws, sessionId, body);
889
+ if (!applied.ok)
890
+ return json(req, res, 502, { ok: false, strategy: actuator.name, error: applied.error });
891
+ return json(req, res, 200, { ok: true, session: reads.listSessions(ws.id).find(s => s.id === sessionId) });
892
+ }
893
+ // POST /api/sessions/:id/stop — the desktop app's stop button, for one chat.
894
+ m = pathname.match(/^\/api\/sessions\/([^/]+)\/stop$/);
895
+ if (req.method === 'POST' && m) {
896
+ const sessionId = decodeURIComponent(m[1]);
897
+ const body = JSON.parse((await readBody(req)) || '{}');
898
+ const ws = body.workspaceId
899
+ ? reads.getWorkspace(body.workspaceId)
900
+ : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
901
+ if (!ws)
902
+ return json(req, res, 404, { error: 'workspace for session not found' });
903
+ const located = locateChat(ws, sessionId);
904
+ if ('error' in located)
905
+ return json(req, res, 409, { error: located.error });
906
+ // Nothing running is a success, not an error: the phone shows Stop the moment it
907
+ // sends (the optimistic hint) and a turn that ends on its own a beat before the tap
908
+ // is the common case, not a mistake worth a red banner. It also keeps the one
909
+ // keystroke this route presses off an idle chat entirely — Conductor's own
910
+ // composer has no stop button to mis-tap there either.
911
+ const before = reads.listSessions(ws.id).find(s => s.id === sessionId);
912
+ if (before?.status !== 'working') {
913
+ return json(req, res, 200, { ok: true, alreadyIdle: true, session: before });
914
+ }
915
+ const result = await stopTurn({ workspace: ws, sessionId, tab: located.tab });
916
+ if (!result.ok)
917
+ return json(req, res, 502, result);
918
+ // The DB is the receipt, exactly as it is for agent settings: the keystroke is
919
+ // fire-and-forget, so what counts is `status` leaving `working`. Conductor writes
920
+ // that a beat after it tears the turn down.
921
+ let observed = before.status;
922
+ for (let i = 0; i < 20 && observed === 'working'; i++) {
923
+ await sleep(300);
924
+ observed = reads.listSessions(ws.id).find(s => s.id === sessionId)?.status ?? observed;
925
+ }
926
+ if (observed === 'working') {
927
+ return json(req, res, 502, {
928
+ ok: false,
929
+ strategy: result.strategy,
930
+ error: 'Conductor took the stop but the agent is still working. Try again, or stop it on your Mac.'
931
+ });
932
+ }
933
+ return json(req, res, 200, {
934
+ ok: true,
825
935
  strategy: result.strategy,
826
- error: 'Conductor took the stop but the agent is still working. Try again, or stop it on your Mac.'
936
+ session: reads.listSessions(ws.id).find(s => s.id === sessionId)
827
937
  });
828
938
  }
829
- return json(req, res, 200, {
830
- ok: true,
831
- strategy: result.strategy,
832
- session: reads.listSessions(ws.id).find(s => s.id === sessionId)
833
- });
834
- }
835
- // POST /api/sessions/:id/prompt { text, agent? } — agent is the phone's staged
836
- // settings patch, applied before the prompt so the two can't come apart (and so
837
- // both park together when the Mac turns out to be locked).
838
- m = pathname.match(/^\/api\/sessions\/([^/]+)\/prompt$/);
839
- if (req.method === 'POST' && m) {
840
- const sessionId = decodeURIComponent(m[1]);
841
- const body = JSON.parse((await readBody(req)) || '{}');
842
- const text = (body.text ?? '').trim();
843
- if (!text)
844
- return json(req, res, 400, { error: 'empty prompt' });
845
- const ws = body.workspaceId
846
- ? reads.getWorkspace(body.workspaceId)
847
- : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
848
- if (!ws)
849
- return json(req, res, 404, { error: 'workspace for session not found' });
850
- // One deadline for the whole request: settings eat into the send's budget
851
- // rather than extending it past what the phone said it would wait.
852
- const deadline = Date.now() + sendBudget(req);
853
- const agent = body.agent && Object.keys(body.agent).length ? body.agent : undefined;
854
- if (agent?.effort && !EFFORT_LABELS[agent.effort]) {
855
- return json(req, res, 400, { error: `effort must be one of ${Object.keys(EFFORT_LABELS).join(', ')}` });
856
- }
857
- if (agent) {
858
- const applied = await applyAgentPatch(ws, sessionId, agent);
859
- if (!applied.ok) {
860
- if (lockBlocked(applied.error)) {
861
- const queued = parkedPrompts.park(ws.id, sessionId, text, agent);
862
- return json(req, res, 202, {
863
- ok: false,
864
- parked: true,
865
- queued,
866
- strategy: actuator.name,
867
- error: PARKED_ERROR
868
- });
939
+ // POST /api/sessions/:id/prompt { text, agent? } — agent is the phone's staged
940
+ // settings patch, applied before the prompt so the two can't come apart (and so
941
+ // both park together when the Mac turns out to be locked).
942
+ m = pathname.match(/^\/api\/sessions\/([^/]+)\/prompt$/);
943
+ if (req.method === 'POST' && m) {
944
+ const sessionId = decodeURIComponent(m[1]);
945
+ const body = JSON.parse((await readBody(req)) || '{}');
946
+ const text = (body.text ?? '').trim();
947
+ if (!text)
948
+ return json(req, res, 400, { error: 'empty prompt' });
949
+ const ws = body.workspaceId
950
+ ? reads.getWorkspace(body.workspaceId)
951
+ : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
952
+ if (!ws)
953
+ return json(req, res, 404, { error: 'workspace for session not found' });
954
+ // One deadline for the whole request: settings eat into the send's budget
955
+ // rather than extending it past what the phone said it would wait.
956
+ const deadline = Date.now() + sendBudget(req);
957
+ const agent = body.agent && Object.keys(body.agent).length ? body.agent : undefined;
958
+ if (agent?.effort && !EFFORT_LABELS[agent.effort]) {
959
+ return json(req, res, 400, { error: `effort must be one of ${Object.keys(EFFORT_LABELS).join(', ')}` });
960
+ }
961
+ if (agent) {
962
+ const applied = await applyAgentPatch(ws, sessionId, agent);
963
+ if (!applied.ok) {
964
+ if (lockBlocked(applied.error)) {
965
+ const queued = parkedPrompts.park(ws.id, sessionId, text, agent);
966
+ return json(req, res, 202, {
967
+ ok: false,
968
+ parked: true,
969
+ queued,
970
+ strategy: actuator.name,
971
+ error: PARKED_ERROR
972
+ });
973
+ }
974
+ return json(req, res, 502, { ok: false, strategy: actuator.name, error: applied.error });
869
975
  }
870
- return json(req, res, 502, { ok: false, strategy: actuator.name, error: applied.error });
871
976
  }
977
+ // Retries live inside deliverPrompt, confirmed against the transcript each time,
978
+ // and inside the deadline this phone told us it would wait.
979
+ const result = await deliverPrompt(ws, sessionId, text, deadline - Date.now());
980
+ if (result.ok) {
981
+ // Whatever a queue was still holding has now been said by hand — the first
982
+ // prompt (including a failed entry retried from the chat), and any parked
983
+ // copy of this exact text, which delivering again would double.
984
+ firstPrompts.forget(ws.id);
985
+ parkedPrompts.forgetDelivered(sessionId, text);
986
+ return json(req, res, 200, result);
987
+ }
988
+ if (lockBlocked(result.error)) {
989
+ // Settings (if any) already stuck, so the entry parks without them.
990
+ const queued = parkedPrompts.park(ws.id, sessionId, text);
991
+ return json(req, res, 202, {
992
+ ok: false,
993
+ parked: true,
994
+ queued,
995
+ strategy: result.strategy,
996
+ error: PARKED_ERROR
997
+ });
998
+ }
999
+ return json(req, res, 502, result);
872
1000
  }
873
- // Retries live inside deliverPrompt, confirmed against the transcript each time,
874
- // and inside the deadline this phone told us it would wait.
875
- const result = await deliverPrompt(ws, sessionId, text, deadline - Date.now());
876
- if (result.ok) {
877
- // Whatever a queue was still holding has now been said by hand — the first
878
- // prompt (including a failed entry retried from the chat), and any parked
879
- // copy of this exact text, which delivering again would double.
880
- firstPrompts.forget(ws.id);
881
- parkedPrompts.forgetDelivered(sessionId, text);
882
- return json(req, res, 200, result);
1001
+ // DELETE /api/workspaces/:id/prompt — dismiss an undelivered first prompt
1002
+ m = pathname.match(/^\/api\/workspaces\/([^/]+)\/prompt$/);
1003
+ if (req.method === 'DELETE' && m) {
1004
+ const workspaceId = decodeURIComponent(m[1]);
1005
+ if (!firstPrompts.forget(workspaceId))
1006
+ return json(req, res, 404, { error: 'no pending prompt' });
1007
+ return json(req, res, 200, { ok: true });
883
1008
  }
884
- if (lockBlocked(result.error)) {
885
- // Settings (if any) already stuck, so the entry parks without them.
886
- const queued = parkedPrompts.park(ws.id, sessionId, text);
887
- return json(req, res, 202, { ok: false, parked: true, queued, strategy: result.strategy, error: PARKED_ERROR });
1009
+ // DELETE /api/sessions/:id/prompt — dismiss whatever is parked for this chat
1010
+ m = pathname.match(/^\/api\/sessions\/([^/]+)\/prompt$/);
1011
+ if (req.method === 'DELETE' && m) {
1012
+ const sessionId = decodeURIComponent(m[1]);
1013
+ if (!parkedPrompts.forgetSession(sessionId))
1014
+ return json(req, res, 404, { error: 'no parked prompt' });
1015
+ return json(req, res, 200, { ok: true });
888
1016
  }
889
- return json(req, res, 502, result);
890
- }
891
- // DELETE /api/workspaces/:id/prompt — dismiss an undelivered first prompt
892
- m = pathname.match(/^\/api\/workspaces\/([^/]+)\/prompt$/);
893
- if (req.method === 'DELETE' && m) {
894
- const workspaceId = decodeURIComponent(m[1]);
895
- if (!firstPrompts.forget(workspaceId))
896
- return json(req, res, 404, { error: 'no pending prompt' });
897
- return json(req, res, 200, { ok: true });
1017
+ return json(req, res, 404, { error: 'no route', pathname });
898
1018
  }
899
- // DELETE /api/sessions/:id/prompt — dismiss whatever is parked for this chat
900
- m = pathname.match(/^\/api\/sessions\/([^/]+)\/prompt$/);
901
- if (req.method === 'DELETE' && m) {
902
- const sessionId = decodeURIComponent(m[1]);
903
- if (!parkedPrompts.forgetSession(sessionId))
904
- return json(req, res, 404, { error: 'no parked prompt' });
905
- return json(req, res, 200, { ok: true });
1019
+ catch (err) {
1020
+ // A refused UI turn is not a server fault and a retry is the right move, so it gets
1021
+ // 503 + Retry-After rather than a 500 that reads as "the relay is broken".
1022
+ if (err instanceof UiBusyError) {
1023
+ res.setHeader('retry-after', '15');
1024
+ return json(req, res, 503, { error: err.message, busy: true, queue: uiQueueDepth() });
1025
+ }
1026
+ // Log the detail locally; don't reflect internals (paths, stack strings) back over the wire.
1027
+ console.error(`[relay] ${req.method} ${pathname} failed:`, err);
1028
+ return json(req, res, 500, { error: 'internal error' });
906
1029
  }
907
- return json(req, res, 404, { error: 'no route', pathname });
908
- }
909
- catch (err) {
910
- // Log the detail locally; don't reflect internals (paths, stack strings) back over the wire.
911
- console.error(`[relay] ${req.method} ${pathname} failed:`, err);
912
- return json(req, res, 500, { error: 'internal error' });
913
- }
1030
+ });
914
1031
  });
915
1032
  server.listen(cfg.port, cfg.host, () => {
916
1033
  console.info([