conductor-remote 1.41.0 → 1.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -0
- package/bin/cli.js +6 -0
- package/dist/assets/{index-BSDnFvjd.js → index-BNMm_aez.js} +2 -2
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/dist-node/src/mcp-tools.js +367 -0
- package/dist-node/src/mcp.js +135 -0
- package/dist-node/src/server.js +606 -489
- package/dist-node/src/writes.js +78 -20
- package/package.json +4 -3
package/dist-node/src/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
//
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
remaining.
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
return json(req, res,
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
//
|
|
579
|
-
if (
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
-
//
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
728
|
-
if (
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
return json(req, res,
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
error:
|
|
745
|
-
|
|
746
|
-
|
|
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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
//
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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
|
-
|
|
936
|
+
session: reads.listSessions(ws.id).find(s => s.id === sessionId)
|
|
827
937
|
});
|
|
828
938
|
}
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
-
//
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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,
|
|
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
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
return json(req, res,
|
|
905
|
-
|
|
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
|
-
|
|
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([
|