conductor-remote 1.40.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,16 +10,18 @@ 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";
16
17
  import { ParkedPromptQueue } from "./parked.js";
17
18
  import { attachPrStatus } from "./pr.js";
18
19
  import { Reads } from "./reads.js";
20
+ import { foldHits, queryTokens, SearchIndex } from "./search.js";
19
21
  import { readSettings, writeSettings } from "./settings.js";
20
22
  import { driftWarningLines, tailscaleBin } from "./tailscale.js";
21
23
  import { autoJoinHotspotMode, currentSsid, looksLikeHotspot, preferredNetworks } from "./wifi.js";
22
- 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";
23
25
  // Before anything that logs: from here on every console line is also kept in memory for
24
26
  // `GET /api/logs`, so the phone can read why a send failed without ssh-ing into the Mac.
25
27
  installLogCapture();
@@ -27,6 +29,43 @@ const cfg = loadConfig();
27
29
  const db = new ConductorDb(cfg.dbPath);
28
30
  const reads = new Reads(db, cfg.workspacesRoot);
29
31
  const actuator = pickActuator(cfg.writeStrategy);
32
+ // Full-text index over the chat prose, in the relay's own sidecar DB — never in
33
+ // Conductor's (see src/search.ts). It backfills in the background and is disposable:
34
+ // deleting the file rebuilds it on the next start.
35
+ const search = new SearchIndex(db, path.join(stateDir(), 'search.db'));
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
+ });
30
69
  // A windowless Conductor that ignores reopen *and* a Dock click can only be fixed
31
70
  // by restarting it — and quitting takes any agent mid-turn down with it. So the
32
71
  // write path may only do that while nothing is working, which is a DB fact, not
@@ -220,13 +259,14 @@ const firstPrompts = new FirstPromptQueue(path.join(stateDir(), 'first-prompts.j
220
259
  alreadySent: !!session?.last_user_message_at
221
260
  };
222
261
  },
223
- 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 () => {
224
264
  const ws = reads.getWorkspace(workspaceId);
225
265
  if (!ws)
226
266
  return { ok: false, error: 'the workspace is gone' };
227
267
  const result = await deliverPrompt(ws, sessionId, text);
228
268
  return { ok: result.ok, error: result.error, blocked: lockBlocked(result.error) };
229
- },
269
+ }),
230
270
  // A locked Mac holds first prompts whole — no attempts spent, no aging — instead
231
271
  // of burning all three sends into a lock screen nobody is there to see.
232
272
  gate: async () => (await screenLocked()) !== true
@@ -262,7 +302,8 @@ const PARKED_ERROR = 'The Mac is locked — the relay parked the prompt and will
262
302
  */
263
303
  const parkedPrompts = new ParkedPromptQueue(path.join(stateDir(), 'parked-prompts.json'), {
264
304
  locked: screenLocked,
265
- deliver: async (entry) => {
305
+ // Delivers on unlock, on its own schedule — background, like the first-prompt queue.
306
+ deliver: entry => withUiPriority('background', async () => {
266
307
  const ws = reads.getWorkspace(entry.workspaceId);
267
308
  if (!ws)
268
309
  return { ok: false, error: 'the workspace is gone' };
@@ -278,7 +319,7 @@ const parkedPrompts = new ParkedPromptQueue(path.join(stateDir(), 'parked-prompt
278
319
  }
279
320
  const result = await deliverPrompt(ws, entry.sessionId, entry.text);
280
321
  return { ok: result.ok, error: result.error, blocked: lockBlocked(result.error) };
281
- },
322
+ }),
282
323
  notify: (entry, error) => {
283
324
  const ws = reads.getWorkspace(entry.workspaceId);
284
325
  const title = ws?.workspace_name ?? ws?.pr_title ?? ws?.branch ?? 'Conductor';
@@ -386,482 +427,607 @@ function serveStatic(_req, res, pathname) {
386
427
  res.end(data);
387
428
  });
388
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
+ }
389
483
  const server = http.createServer(async (req, res) => {
390
484
  const url = new URL(req.url ?? '/', 'http://x');
391
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);
392
496
  if (!pathname.startsWith('/api/'))
393
497
  return serveStatic(req, res, pathname);
394
498
  // Everything under /api requires the shared secret.
395
499
  if (!authed(req))
396
500
  return json(req, res, 401, { error: 'unauthorized' });
397
- try {
398
- // GET /api/state — workspace list with active-session status
399
- if (req.method === 'GET' && pathname === '/api/state') {
400
- const update = updateStatus();
401
- const workspaces = reads.listWorkspaces();
402
- attachPrStatus(workspaces); // colours pr_status from cache; refreshes stale entries in the background
403
- // An undelivered first prompt rides along with its workspace: the phone renders it
404
- // in that chat rather than tracking delivery itself (see src/firstprompt.ts).
405
- // Prompts parked for the lock screen ride the same way, one list per workspace,
406
- // each entry naming its chat (src/parked.ts).
407
- const parked = parkedPrompts.list();
408
- for (const ws of workspaces) {
409
- ws.pending_prompt = firstPrompts.get(ws.id);
410
- const mine = parked.filter(p => p.workspaceId === ws.id);
411
- if (mine.length)
412
- 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
+ });
413
530
  }
414
- return json(req, res, 200, {
415
- workspaces,
416
- actuator: await describeActuator(actuator),
417
- version: update.current,
418
- update
419
- });
420
- }
421
- // GET /api/repos — repos a new workspace can be created in
422
- if (req.method === 'GET' && pathname === '/api/repos') {
423
- return json(req, res, 200, { repos: reads.listRepos() });
424
- }
425
- // GET /api/settings — relay preferences plus what the phone needs to edit them:
426
- // the SSIDs this Mac already holds credentials for, so the picker offers a choice
427
- // instead of asking someone to type a network name from memory on a phone keyboard.
428
- // `ssid` is best-effort and often null (macOS gates it behind Location Services).
429
- if (req.method === 'GET' && pathname === '/api/settings') {
430
- // Four subprocesses, all concurrent: this is the one route that shells out more
431
- // than once, and serialising them would put the phone's polls behind the sum.
432
- const [known, current, autoJoinHotspot, nosleep] = await Promise.all([
433
- preferredNetworks(),
434
- currentSsid(),
435
- // macOS's own Auto-join Hotspot setting. On "Never" the Mac won't reach for
436
- // your phone unprompted, which no amount of relay code can substitute for.
437
- autoJoinHotspotMode(),
438
- nosleepState()
439
- ]);
440
- return json(req, res, 200, {
441
- settings: readSettings(),
442
- wifi: {
443
- current,
444
- known,
445
- // A guess from the name, never a fact — see wifi.ts. It only sorts the picker.
446
- likelyHotspots: known.filter(looksLikeHotspot),
447
- autoJoinHotspot
448
- },
449
- nosleep: { ...nosleep, maxSeconds: NOSLEEP_MAX_SECONDS }
450
- });
451
- }
452
- // PATCH /api/settings { fallbackSsids?, autoRejoin? } — merge and persist.
453
- if (req.method === 'PATCH' && pathname === '/api/settings') {
454
- const body = JSON.parse((await readBody(req)) || '{}');
455
- const patch = {};
456
- if (Array.isArray(body.fallbackSsids))
457
- patch.fallbackSsids = body.fallbackSsids;
458
- if (typeof body.autoRejoin === 'boolean')
459
- patch.autoRejoin = body.autoRejoin;
460
- if (Object.keys(patch).length === 0)
461
- return json(req, res, 400, { error: 'nothing to change' });
462
- return json(req, res, 200, { settings: writeSettings(patch) });
463
- }
464
- // GET /api/nosleep — is the Mac being held awake, and can this relay do it at all
465
- if (req.method === 'GET' && pathname === '/api/nosleep') {
466
- return json(req, res, 200, { ...(await nosleepState()), maxSeconds: NOSLEEP_MAX_SECONDS });
467
- }
468
- // POST /api/nosleep { seconds } — hold this Mac awake, lid closed, for a bounded window.
469
- // Only works once `conductor-remote nosleep setup` has installed the scoped sudoers
470
- // rule; without it there is no way for a TTY-less daemon to reach root, and the
471
- // response says so rather than failing vaguely.
472
- if (req.method === 'POST' && pathname === '/api/nosleep') {
473
- const body = JSON.parse((await readBody(req)) || '{}');
474
- const seconds = Number(body.seconds);
475
- // Whole seconds, not just "> 0": the helper reads 0 as "until killed", and 0.4
476
- // truncates to 0 — an unbounded window from a request that looked bounded.
477
- if (!Number.isInteger(seconds) || seconds < 1)
478
- return json(req, res, 400, { error: 'need a whole number of seconds >= 1' });
479
- const result = await armNoSleep(seconds);
480
- return json(req, res, result.ok ? 200 : result.state.available ? 502 : 409, result);
481
- }
482
- // DELETE /api/nosleep — let it sleep again now, rather than at the window's end
483
- if (req.method === 'DELETE' && pathname === '/api/nosleep') {
484
- const result = await disarmNoSleep();
485
- return json(req, res, result.ok ? 200 : result.state.available ? 502 : 409, result);
486
- }
487
- // GET /api/logs?file=&limit= — the relay's own log, so a phone can diagnose a failed send
488
- // without reaching the Mac. Default is this process's captured console (ordered, timestamped);
489
- // `file` tails the daemon's stdout/stderr on disk, which is the only place the *previous*
490
- // process's crash survives. Everything is redacted: the startup banner prints the token.
491
- if (req.method === 'GET' && pathname === '/api/logs') {
492
- const file = url.searchParams.get('file');
493
- if (file && !LOG_FILE_NAMES.includes(file)) {
494
- return json(req, res, 404, { error: `unknown log file ${file}`, files: LOG_FILE_NAMES });
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
+ });
495
573
  }
496
- const asked = Number(url.searchParams.get('limit') ?? 300);
497
- const limit = Number.isFinite(asked) ? Math.min(2000, Math.max(1, Math.trunc(asked))) : 300;
498
- let entries;
499
- try {
500
- entries = file ? tailLogFile(file, limit) : recentLogs(limit);
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() });
501
577
  }
502
- catch (err) {
503
- // The file only exists once the LaunchAgent has run; say so instead of a bare 500.
504
- return json(req, res, 404, { error: `can’t read ${file}: ${err instanceof Error ? err.message : err}` });
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
+ });
505
604
  }
506
- return json(req, res, 200, {
507
- source: file ?? 'live',
508
- // False → the files below are some *other* (daemon) process's output, not this relay's.
509
- managed: isManaged(),
510
- startedAt: processStartedAt(),
511
- now: Date.now(),
512
- files: logFiles(),
513
- entries: entries.map(e => ({ ...e, text: redactSecrets(e.text, cfg.token) }))
514
- });
515
- }
516
- // GET /api/push — the VAPID public key the phone subscribes with, plus who's already subscribed
517
- if (req.method === 'GET' && pathname === '/api/push') {
518
- return json(req, res, 200, pushConfig());
519
- }
520
- // POST /api/push/subscribe { subscription, label? } — register (or refresh) this device.
521
- // Idempotent by endpoint: the app re-sends on every load, which is what heals a relay that
522
- // lost its store, or a subscription the browser silently renewed.
523
- if (req.method === 'POST' && pathname === '/api/push/subscribe') {
524
- const body = JSON.parse((await readBody(req)) || '{}');
525
- const sub = body.subscription;
526
- if (!sub?.endpoint || !sub.keys?.p256dh || !sub.keys.auth) {
527
- return json(req, res, 400, { error: 'need a subscription with endpoint and keys' });
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) });
528
616
  }
529
- // An endpoint is a URL we will POST to — never accept a non-HTTPS one.
530
- if (!/^https:\/\//i.test(sub.endpoint))
531
- return json(req, res, 400, { error: 'endpoint must be https' });
532
- const registered = subscribeDevice({ endpoint: sub.endpoint, keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth } }, (body.label ?? '').slice(0, 64));
533
- return json(req, res, 200, { ok: true, ...registered });
534
- }
535
- // POST /api/push/unsubscribe { endpoint } — the phone turned notifications off
536
- if (req.method === 'POST' && pathname === '/api/push/unsubscribe') {
537
- const body = JSON.parse((await readBody(req)) || '{}');
538
- if (!body.endpoint)
539
- return json(req, res, 400, { error: 'need the endpoint' });
540
- return json(req, res, 200, { ok: unsubscribeDevice(body.endpoint), devices: pushConfig().devices });
541
- }
542
- // POST /api/push/test { id } — push to one device, so "is this actually wired up?" has an answer
543
- if (req.method === 'POST' && pathname === '/api/push/test') {
544
- const body = JSON.parse((await readBody(req)) || '{}');
545
- if (!body.id)
546
- return json(req, res, 400, { error: 'need the device id' });
547
- const result = await notifyDevice(body.id, {
548
- title: 'Conductor Remote',
549
- body: 'Notifications are working. You’ll get one when an agent finishes.',
550
- tag: 'test',
551
- url: '/',
552
- kind: 'test',
553
- ts: Date.now()
554
- });
555
- return json(req, res, result.ok ? 200 : 502, result);
556
- }
557
- // POST /api/workspaces { repo, prompt, send? } — create a workspace via Conductor's deep link
558
- if (req.method === 'POST' && pathname === '/api/workspaces') {
559
- const body = JSON.parse((await readBody(req)) || '{}');
560
- // The prompt is optional — a bare `path=` opens an empty workspace, like
561
- // Conductor's own New workspace — but *something* has to say where it goes.
562
- const prompt = (body.prompt ?? '').trim();
563
- if (!prompt && !body.repo)
564
- return json(req, res, 400, { error: 'need a repo or a prompt' });
565
- // Resolve the repo to a real path: an unmatched `path` would silently land
566
- // the workspace in whichever repo Conductor happens to list first.
567
- const repo = body.repo ? reads.listRepos().find(r => r.name === body.repo) : undefined;
568
- if (body.repo && !repo)
569
- return json(req, res, 404, { error: `unknown repo ${body.repo}` });
570
- if (repo && !repo.root_path)
571
- return json(req, res, 409, { error: `${repo.name} has no checkout path` });
572
- const before = new Set(reads.listWorkspaces().map(w => w.id));
573
- const result = await createWorkspace(prompt, repo?.root_path ?? null);
574
- if (!result.ok)
575
- return json(req, res, 502, result);
576
- // The deep link is fire-and-forget, so the new row is the only proof it worked.
577
- // Creating a worktree takes a beat longer than opening a chat does.
578
- let created;
579
- for (let attempt = 0; attempt < 40 && !created; attempt++) {
580
- await sleep(500);
581
- created = reads.listWorkspaces().find(w => !before.has(w.id));
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 });
582
620
  }
583
- if (!created) {
584
- return json(req, res, 502, {
585
- ok: false,
586
- strategy: result.strategy,
587
- error: 'Conductor didn’t create a workspace — check it’s running and not showing a dialog.'
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);
634
+ }
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);
639
+ }
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) }))
588
667
  });
589
668
  }
590
- // Return as soon as the row exists (~2s) — waiting for delivery would block the
591
- // request through Conductor's whole setup, measured at 30s+ on a real repo and
592
- // past any budget a phone should hold a request open for. The queue delivers on
593
- // its own schedule and the phone watches it in /api/state; `send:true` opts API
594
- // callers into waiting.
595
- // Whatever happens, the prompt is already pre-filled in Conductor's composer.
596
- const settled = prompt ? firstPrompts.enqueue(created.id, prompt) : null;
597
- const failed = settled && body.send === true ? await settled : null;
598
- settled?.catch(() => undefined); // fire-and-forget: it reports failure, it never rejects
599
- return json(req, res, 200, {
600
- ok: true,
601
- workspaceId: created.id,
602
- workspace: reads.getWorkspace(created.id) ?? created,
603
- pendingPrompt: prompt || undefined,
604
- sent: body.send === true ? !failed : false,
605
- warning: failed?.error && `Workspace created; the prompt is pre-filled but wasn’t sent (${failed.error}).`
606
- });
607
- }
608
- // GET /api/repos/:name/icon — the repo's resolved sidebar icon (see src/icons.ts)
609
- let m = pathname.match(/^\/api\/repos\/([^/]+)\/icon$/);
610
- if (req.method === 'GET' && m) {
611
- const icon = reads.resolveRepoIcon(decodeURIComponent(m[1]));
612
- if (!icon)
613
- return json(req, res, 404, { error: 'no icon' });
614
- return void fs.readFile(icon.path, (err, data) => {
615
- if (err)
616
- return void json(req, res, 404, { error: 'no icon' });
617
- // Cache briefly on the phone; the resolver itself refreshes within ~30s of an icon change.
618
- res.writeHead(200, { 'content-type': icon.contentType, 'cache-control': 'public, max-age=300' });
619
- res.end(data);
620
- });
621
- }
622
- // GET /api/workspaces/:id/sessions
623
- m = pathname.match(/^\/api\/workspaces\/([^/]+)\/sessions$/);
624
- if (req.method === 'GET' && m) {
625
- return json(req, res, 200, { sessions: reads.listSessions(decodeURIComponent(m[1])) });
626
- }
627
- // POST /api/workspaces/:id/sessions — open a new chat (Cmd+T) in the workspace
628
- if (req.method === 'POST' && m) {
629
- const workspaceId = decodeURIComponent(m[1]);
630
- const ws = reads.getWorkspace(workspaceId);
631
- if (!ws)
632
- return json(req, res, 404, { error: 'workspace not found' });
633
- const before = new Set(reads.listSessions(workspaceId).map(s => s.id));
634
- const result = await newChat(ws);
635
- if (!result.ok)
636
- return json(req, res, 502, result);
637
- // The new session lands in the DB a beat after Cmd+T — poll for the fresh id.
638
- let sessionId = null;
639
- for (let i = 0; i < 12 && !sessionId; i++) {
640
- await new Promise(r => setTimeout(r, 500));
641
- 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());
642
672
  }
643
- return json(req, res, 200, { ok: true, sessionId });
644
- }
645
- // GET /api/workspaces/:id/diff
646
- m = pathname.match(/^\/api\/workspaces\/([^/]+)\/diff$/);
647
- if (req.method === 'GET' && m) {
648
- const ws = reads.getWorkspace(decodeURIComponent(m[1]));
649
- if (!ws)
650
- return json(req, res, 404, { error: 'workspace not found' });
651
- if (!ws.worktree)
652
- return json(req, res, 409, { error: 'worktree path unresolved' });
653
- const diff = await workspaceDiff(ws.worktree, ws.baseBranch);
654
- return json(req, res, 200, diff);
655
- }
656
- // POST /api/workspaces/:id/merge — merge the workspace's open PR (mirrors Conductor's merge button)
657
- m = pathname.match(/^\/api\/workspaces\/([^/]+)\/merge$/);
658
- if (req.method === 'POST' && m) {
659
- const ws = reads.getWorkspace(decodeURIComponent(m[1]));
660
- if (!ws)
661
- return json(req, res, 404, { error: 'workspace not found' });
662
- const result = await mergePr(ws);
663
- return json(req, res, result.ok ? 200 : 409, result);
664
- }
665
- // POST /api/workspaces/:id/status { status } — move it between the sidebar's status groups.
666
- // Conductor derives that status from a PR it sometimes never links (a PR merged inside its
667
- // poll window is invisible to it afterwards), which strands finished work in "In progress"
668
- // with no way to correct it from a phone. This is that way.
669
- m = pathname.match(/^\/api\/workspaces\/([^/]+)\/status$/);
670
- if (req.method === 'POST' && m) {
671
- const workspaceId = decodeURIComponent(m[1]);
672
- const body = JSON.parse((await readBody(req)) || '{}');
673
- const status = body.status ?? '';
674
- if (!WORKSPACE_STATUS_LABELS[status]) {
675
- const allowed = Object.keys(WORKSPACE_STATUS_LABELS).join(', ');
676
- 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 });
677
687
  }
678
- const ws = reads.getWorkspace(workspaceId);
679
- if (!ws)
680
- return json(req, res, 404, { error: 'workspace not found' });
681
- const result = await setWorkspaceStatus(ws, status);
682
- if (!result.ok)
683
- return json(req, res, 502, result);
684
- // The menu press lands in the DB a beat later. Confirm rather than assume —
685
- // and if Conductor wrote something else, say what, instead of "didn't work".
686
- let observed = ws.manual_status ?? '';
687
- for (let i = 0; i < 10 && observed !== status; i++) {
688
- await new Promise(r => setTimeout(r, 300));
689
- 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 });
690
694
  }
691
- if (observed !== status) {
692
- return json(req, res, 502, {
693
- ok: false,
694
- strategy: result.strategy,
695
- error: observed
696
- ? `Conductor recorded the status as “${observed}”, not “${status}”.`
697
- : '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()
698
707
  });
708
+ return json(req, res, result.ok ? 200 : 502, result);
699
709
  }
700
- return json(req, res, 200, { ok: true, workspace: reads.getWorkspace(workspaceId) });
701
- }
702
- // GET /api/sessions/:id/messages?after=<rowid>
703
- m = pathname.match(/^\/api\/sessions\/([^/]+)\/messages$/);
704
- if (req.method === 'GET' && m) {
705
- const after = Number(url.searchParams.get('after') ?? 0);
706
- return json(req, res, 200, reads.getMessages(decodeURIComponent(m[1]), Number.isFinite(after) ? after : 0));
707
- }
708
- // GET /api/sessions/:id/models?workspaceId= — labels from Conductor's live picker
709
- m = pathname.match(/^\/api\/sessions\/([^/]+)\/models$/);
710
- if (req.method === 'GET' && m) {
711
- const sessionId = decodeURIComponent(m[1]);
712
- const ws = reads.getWorkspace(url.searchParams.get('workspaceId') ?? '');
713
- if (!ws)
714
- return json(req, res, 404, { error: 'workspace for session not found' });
715
- const located = locateChat(ws, sessionId);
716
- if ('error' in located)
717
- return json(req, res, 409, { error: located.error });
718
- const result = await listAgentModels({ workspace: ws, sessionId, tab: located.tab });
719
- return json(req, res, result.ok ? 200 : 502, result);
720
- }
721
- // POST /api/sessions/:id/agent { effort?, plan?, fast?, model? }
722
- // Drives the composer's own model/effort/plan/fast controls for one chat.
723
- m = pathname.match(/^\/api\/sessions\/([^/]+)\/agent$/);
724
- if (req.method === 'POST' && m) {
725
- const sessionId = decodeURIComponent(m[1]);
726
- const body = JSON.parse((await readBody(req)) || '{}');
727
- if (body.effort && !EFFORT_LABELS[body.effort]) {
728
- 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
+ });
729
760
  }
730
- const ws = body.workspaceId
731
- ? reads.getWorkspace(body.workspaceId)
732
- : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
733
- if (!ws)
734
- return json(req, res, 404, { error: 'workspace for session not found' });
735
- const applied = await applyAgentPatch(ws, sessionId, body);
736
- if (!applied.ok)
737
- return json(req, res, 502, { ok: false, strategy: actuator.name, error: applied.error });
738
- return json(req, res, 200, { ok: true, session: reads.listSessions(ws.id).find(s => s.id === sessionId) });
739
- }
740
- // POST /api/sessions/:id/stop — the desktop app's stop button, for one chat.
741
- m = pathname.match(/^\/api\/sessions\/([^/]+)\/stop$/);
742
- if (req.method === 'POST' && m) {
743
- const sessionId = decodeURIComponent(m[1]);
744
- const body = JSON.parse((await readBody(req)) || '{}');
745
- const ws = body.workspaceId
746
- ? reads.getWorkspace(body.workspaceId)
747
- : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
748
- if (!ws)
749
- return json(req, res, 404, { error: 'workspace for session not found' });
750
- const located = locateChat(ws, sessionId);
751
- if ('error' in located)
752
- return json(req, res, 409, { error: located.error });
753
- // Nothing running is a success, not an error: the phone shows Stop the moment it
754
- // sends (the optimistic hint) and a turn that ends on its own a beat before the tap
755
- // is the common case, not a mistake worth a red banner. It also keeps the one
756
- // keystroke this route presses off an idle chat entirely — Conductor's own
757
- // composer has no stop button to mis-tap there either.
758
- const before = reads.listSessions(ws.id).find(s => s.id === sessionId);
759
- if (before?.status !== 'working') {
760
- 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
+ });
761
774
  }
762
- const result = await stopTurn({ workspace: ws, sessionId, tab: located.tab });
763
- if (!result.ok)
764
- return json(req, res, 502, result);
765
- // The DB is the receipt, exactly as it is for agent settings: the keystroke is
766
- // fire-and-forget, so what counts is `status` leaving `working`. Conductor writes
767
- // that a beat after it tears the turn down.
768
- let observed = before.status;
769
- for (let i = 0; i < 20 && observed === 'working'; i++) {
770
- await sleep(300);
771
- 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);
772
817
  }
773
- if (observed === 'working') {
774
- return json(req, res, 502, {
775
- 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,
776
935
  strategy: result.strategy,
777
- 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)
778
937
  });
779
938
  }
780
- return json(req, res, 200, {
781
- ok: true,
782
- strategy: result.strategy,
783
- session: reads.listSessions(ws.id).find(s => s.id === sessionId)
784
- });
785
- }
786
- // POST /api/sessions/:id/prompt { text, agent? } — agent is the phone's staged
787
- // settings patch, applied before the prompt so the two can't come apart (and so
788
- // both park together when the Mac turns out to be locked).
789
- m = pathname.match(/^\/api\/sessions\/([^/]+)\/prompt$/);
790
- if (req.method === 'POST' && m) {
791
- const sessionId = decodeURIComponent(m[1]);
792
- const body = JSON.parse((await readBody(req)) || '{}');
793
- const text = (body.text ?? '').trim();
794
- if (!text)
795
- return json(req, res, 400, { error: 'empty prompt' });
796
- const ws = body.workspaceId
797
- ? reads.getWorkspace(body.workspaceId)
798
- : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
799
- if (!ws)
800
- return json(req, res, 404, { error: 'workspace for session not found' });
801
- // One deadline for the whole request: settings eat into the send's budget
802
- // rather than extending it past what the phone said it would wait.
803
- const deadline = Date.now() + sendBudget(req);
804
- const agent = body.agent && Object.keys(body.agent).length ? body.agent : undefined;
805
- if (agent?.effort && !EFFORT_LABELS[agent.effort]) {
806
- return json(req, res, 400, { error: `effort must be one of ${Object.keys(EFFORT_LABELS).join(', ')}` });
807
- }
808
- if (agent) {
809
- const applied = await applyAgentPatch(ws, sessionId, agent);
810
- if (!applied.ok) {
811
- if (lockBlocked(applied.error)) {
812
- const queued = parkedPrompts.park(ws.id, sessionId, text, agent);
813
- return json(req, res, 202, {
814
- ok: false,
815
- parked: true,
816
- queued,
817
- strategy: actuator.name,
818
- error: PARKED_ERROR
819
- });
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 });
820
975
  }
821
- return json(req, res, 502, { ok: false, strategy: actuator.name, error: applied.error });
822
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);
823
1000
  }
824
- // Retries live inside deliverPrompt, confirmed against the transcript each time,
825
- // and inside the deadline this phone told us it would wait.
826
- const result = await deliverPrompt(ws, sessionId, text, deadline - Date.now());
827
- if (result.ok) {
828
- // Whatever a queue was still holding has now been said by hand — the first
829
- // prompt (including a failed entry retried from the chat), and any parked
830
- // copy of this exact text, which delivering again would double.
831
- firstPrompts.forget(ws.id);
832
- parkedPrompts.forgetDelivered(sessionId, text);
833
- 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 });
834
1008
  }
835
- if (lockBlocked(result.error)) {
836
- // Settings (if any) already stuck, so the entry parks without them.
837
- const queued = parkedPrompts.park(ws.id, sessionId, text);
838
- 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 });
839
1016
  }
840
- return json(req, res, 502, result);
841
- }
842
- // DELETE /api/workspaces/:id/prompt — dismiss an undelivered first prompt
843
- m = pathname.match(/^\/api\/workspaces\/([^/]+)\/prompt$/);
844
- if (req.method === 'DELETE' && m) {
845
- const workspaceId = decodeURIComponent(m[1]);
846
- if (!firstPrompts.forget(workspaceId))
847
- return json(req, res, 404, { error: 'no pending prompt' });
848
- return json(req, res, 200, { ok: true });
1017
+ return json(req, res, 404, { error: 'no route', pathname });
849
1018
  }
850
- // DELETE /api/sessions/:id/prompt — dismiss whatever is parked for this chat
851
- m = pathname.match(/^\/api\/sessions\/([^/]+)\/prompt$/);
852
- if (req.method === 'DELETE' && m) {
853
- const sessionId = decodeURIComponent(m[1]);
854
- if (!parkedPrompts.forgetSession(sessionId))
855
- return json(req, res, 404, { error: 'no parked prompt' });
856
- 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' });
857
1029
  }
858
- return json(req, res, 404, { error: 'no route', pathname });
859
- }
860
- catch (err) {
861
- // Log the detail locally; don't reflect internals (paths, stack strings) back over the wire.
862
- console.error(`[relay] ${req.method} ${pathname} failed:`, err);
863
- return json(req, res, 500, { error: 'internal error' });
864
- }
1030
+ });
865
1031
  });
866
1032
  server.listen(cfg.port, cfg.host, () => {
867
1033
  console.info([