conductor-remote 1.42.2 → 1.43.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.
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Every `/api` path, declared once, for the three callers that must agree on it.
3
+ *
4
+ * The relay matches these, the phone builds them (`web/src/lib/api.ts`) and the MCP
5
+ * tools build them too (`src/mcp-tools.ts`). Before this each path was spelled three
6
+ * times: a regex here, a template literal there, another template literal in the third
7
+ * place. `src/wire.ts` had already made the *shapes* impossible to disagree about, and
8
+ * this is the other half — a renamed path used to typecheck cleanly in all three files
9
+ * and surface as a 404 on someone's phone.
10
+ *
11
+ * One pattern gives both directions. `param()` splits `/api/sessions/:id/stop` at the
12
+ * placeholder, so the same string builds `path(id)` for a client and the regex the relay
13
+ * matches with. They cannot drift because there is only one of them.
14
+ *
15
+ * **This module stays stdlib-free — no `node:` imports, ever.** It is one of the two
16
+ * files under `src/` the web app may import a *value* from (the other is
17
+ * `src/shared.ts`), and `scripts/check-imports.ts` walks both to enforce it. Anything
18
+ * needing Node belongs in the handler, not in the table.
19
+ */
20
+ /** Escape a literal for use inside a RegExp — the paths are fixed strings, but `.` is real syntax. */
21
+ function literal(text) {
22
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
23
+ }
24
+ function flat(method, pattern) {
25
+ return { method, pattern, path: () => pattern };
26
+ }
27
+ function param(method, pattern) {
28
+ const [head, tail] = pattern.split(/:[a-zA-Z]+/);
29
+ if (tail === undefined)
30
+ throw new Error(`route ${pattern} declares no :param`);
31
+ return {
32
+ method,
33
+ pattern,
34
+ path: value => `${head}${encodeURIComponent(value)}${tail}`,
35
+ re: new RegExp(`^${literal(head)}([^/]+)${literal(tail)}$`)
36
+ };
37
+ }
38
+ /**
39
+ * The table. Names read as what the caller wants, not as the verb plus the noun, because
40
+ * the method is right there in the value.
41
+ */
42
+ export const routes = {
43
+ // ── the relay itself ──
44
+ state: flat('GET', '/api/state'),
45
+ search: flat('GET', '/api/search'),
46
+ repos: flat('GET', '/api/repos'),
47
+ repoIcon: param('GET', '/api/repos/:repo/icon'),
48
+ logs: flat('GET', '/api/logs'),
49
+ settings: flat('GET', '/api/settings'),
50
+ updateSettings: flat('PATCH', '/api/settings'),
51
+ nosleep: flat('GET', '/api/nosleep'),
52
+ armNoSleep: flat('POST', '/api/nosleep'),
53
+ disarmNoSleep: flat('DELETE', '/api/nosleep'),
54
+ push: flat('GET', '/api/push'),
55
+ pushSubscribe: flat('POST', '/api/push/subscribe'),
56
+ pushUnsubscribe: flat('POST', '/api/push/unsubscribe'),
57
+ pushTest: flat('POST', '/api/push/test'),
58
+ // ── workspaces ──
59
+ createWorkspace: flat('POST', '/api/workspaces'),
60
+ sessions: param('GET', '/api/workspaces/:workspaceId/sessions'),
61
+ newChat: param('POST', '/api/workspaces/:workspaceId/sessions'),
62
+ diff: param('GET', '/api/workspaces/:workspaceId/diff'),
63
+ merge: param('POST', '/api/workspaces/:workspaceId/merge'),
64
+ workspaceStatus: param('POST', '/api/workspaces/:workspaceId/status'),
65
+ /** Dismiss a first prompt the relay never managed to deliver (src/firstprompt.ts). */
66
+ dismissFirstPrompt: param('DELETE', '/api/workspaces/:workspaceId/prompt'),
67
+ // ── chats ──
68
+ messages: param('GET', '/api/sessions/:sessionId/messages'),
69
+ models: param('GET', '/api/sessions/:sessionId/models'),
70
+ agent: param('POST', '/api/sessions/:sessionId/agent'),
71
+ stop: param('POST', '/api/sessions/:sessionId/stop'),
72
+ sendPrompt: param('POST', '/api/sessions/:sessionId/prompt'),
73
+ /** Copy a chat into a fresh tab beside it, as a Conductor attachment (src/attachments.ts). */
74
+ splitChat: param('POST', '/api/sessions/:sessionId/split'),
75
+ /** Dismiss a prompt parked behind the lock screen (src/parked.ts). */
76
+ dismissParkedPrompt: param('DELETE', '/api/sessions/:sessionId/prompt')
77
+ };
78
+ // ── matching, for the relay ─────────────────────────────────────────────────────
79
+ // The client half of a route is a function call; the server half needs these two.
80
+ /** Whether this request is that parameterless route. */
81
+ export function isRoute(route, method, pathname) {
82
+ return method === route.method && pathname === route.pattern;
83
+ }
84
+ /**
85
+ * The decoded parameter when this request is that route, else null.
86
+ *
87
+ * Decoding here rather than at each call site is the point: a workspace id is safe
88
+ * either way, but a repo name is not, and one handler forgetting `decodeURIComponent`
89
+ * is exactly the bug this table exists to make unwritable.
90
+ */
91
+ export function routeParam(route, method, pathname) {
92
+ if (method !== route.method)
93
+ return null;
94
+ const m = pathname.match(route.re);
95
+ return m ? decodeURIComponent(m[1]) : null;
96
+ }
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  import http from 'node:http';
4
4
  import path from 'node:path';
5
5
  import zlib from 'node:zlib';
6
+ import { writeAttachment } from "./attachments.js";
6
7
  import { startAutoUpdate, updateStatus } from "./autoupdate.js";
7
8
  import { loadConfig, stateDir } from "./config.js";
8
9
  import { ConductorDb } from "./db.js";
@@ -17,9 +18,11 @@ import { chatRoute, notifyAll, notifyDevice, pushConfig, startNotifier, subscrib
17
18
  import { ParkedPromptQueue } from "./parked.js";
18
19
  import { attachPrStatus } from "./pr.js";
19
20
  import { Reads } from "./reads.js";
21
+ import { isRoute, routeParam, routes } from "./routes.js";
20
22
  import { foldHits, queryTokens, SearchIndex } from "./search.js";
21
23
  import { readSettings, writeSettings } from "./settings.js";
22
24
  import { driftWarningLines, tailscaleBin } from "./tailscale.js";
25
+ import { renderTranscript } from "./transcript.js";
23
26
  import { autoJoinHotspotMode, currentSsid, looksLikeHotspot, preferredNetworks } from "./wifi.js";
24
27
  import { createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, retryWontHelp, screenLocked, setAgentOptions, setRestartGuard, setWorkspaceStatus, stopTurn, UiBusyError, uiQueueDepth, WORKSPACE_STATUS_LABELS, withUiPriority } from "./writes.js";
25
28
  // Before anything that logs: from here on every console line is also kept in memory for
@@ -160,6 +163,31 @@ function locateChat(ws, sessionId) {
160
163
  session: sessions[index]
161
164
  };
162
165
  }
166
+ /**
167
+ * Open a chat tab in a workspace and come back with its id.
168
+ *
169
+ * ⌘T is fire-and-forget like every other keystroke here, so the id is not something
170
+ * the write can return — the DB is the receipt. Which row is the new one is decided by
171
+ * diffing the tab list against the one taken *before* the keystroke, not by taking the
172
+ * newest: a sibling tab or another agent may have opened one in between, and picking by
173
+ * `created_at` would hand back theirs.
174
+ */
175
+ async function openChat(ws) {
176
+ const before = new Set(reads.listSessions(ws.id).map(s => s.id));
177
+ const result = await newChat(ws);
178
+ if (!result.ok)
179
+ return { error: true, result };
180
+ // The new session lands in the DB a beat after Cmd+T — poll for the fresh id.
181
+ for (let i = 0; i < 12; i++) {
182
+ await sleep(500);
183
+ const fresh = reads.listSessions(ws.id).find(s => !before.has(s.id));
184
+ if (fresh)
185
+ return { sessionId: fresh.id };
186
+ }
187
+ // The tab is almost certainly on screen; only its id is missing. Say so rather than
188
+ // failing the call, so a caller can still tell the user where the work went.
189
+ return { sessionId: null };
190
+ }
163
191
  /** Poll the DB until Conductor records the setting we just drove through the UI. */
164
192
  async function confirmAgentOptions(ws, sessionId, opts) {
165
193
  for (let attempt = 0; attempt < 10; attempt++) {
@@ -506,7 +534,7 @@ const server = http.createServer(async (req, res) => {
506
534
  return withUiPriority(priority, async () => {
507
535
  try {
508
536
  // GET /api/state — workspace list with active-session status
509
- if (req.method === 'GET' && pathname === '/api/state') {
537
+ if (isRoute(routes.state, req.method, pathname)) {
510
538
  const update = updateStatus();
511
539
  const workspaces = reads.listWorkspaces();
512
540
  attachPrStatus(workspaces); // colours pr_status from cache; refreshes stale entries in the background
@@ -538,7 +566,7 @@ const server = http.createServer(async (req, res) => {
538
566
  //
539
567
  // Both reach archived workspaces. That is the point: 1,846 of the 1,886 here are
540
568
  // archived, so a search limited to the live sidebar would miss almost everything.
541
- if (req.method === 'GET' && pathname === '/api/search') {
569
+ if (isRoute(routes.search, req.method, pathname)) {
542
570
  const q = url.searchParams.get('q') ?? '';
543
571
  // 12, not 50: an OR query over common words ("add", "remove") has a long weak tail,
544
572
  // and past the first screenful nobody scrolls — they retype instead.
@@ -572,14 +600,14 @@ const server = http.createServer(async (req, res) => {
572
600
  });
573
601
  }
574
602
  // GET /api/repos — repos a new workspace can be created in
575
- if (req.method === 'GET' && pathname === '/api/repos') {
603
+ if (isRoute(routes.repos, req.method, pathname)) {
576
604
  return json(req, res, 200, { repos: reads.listRepos() });
577
605
  }
578
606
  // GET /api/settings — relay preferences plus what the phone needs to edit them:
579
607
  // the SSIDs this Mac already holds credentials for, so the picker offers a choice
580
608
  // instead of asking someone to type a network name from memory on a phone keyboard.
581
609
  // `ssid` is best-effort and often null (macOS gates it behind Location Services).
582
- if (req.method === 'GET' && pathname === '/api/settings') {
610
+ if (isRoute(routes.settings, req.method, pathname)) {
583
611
  // Four subprocesses, all concurrent: this is the one route that shells out more
584
612
  // than once, and serialising them would put the phone's polls behind the sum.
585
613
  const [known, current, autoJoinHotspot, nosleep] = await Promise.all([
@@ -603,7 +631,7 @@ const server = http.createServer(async (req, res) => {
603
631
  });
604
632
  }
605
633
  // PATCH /api/settings { fallbackSsids?, autoRejoin? } — merge and persist.
606
- if (req.method === 'PATCH' && pathname === '/api/settings') {
634
+ if (isRoute(routes.updateSettings, req.method, pathname)) {
607
635
  const body = JSON.parse((await readBody(req)) || '{}');
608
636
  const patch = {};
609
637
  if (Array.isArray(body.fallbackSsids))
@@ -615,14 +643,14 @@ const server = http.createServer(async (req, res) => {
615
643
  return json(req, res, 200, { settings: writeSettings(patch) });
616
644
  }
617
645
  // 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') {
646
+ if (isRoute(routes.nosleep, req.method, pathname)) {
619
647
  return json(req, res, 200, { ...(await nosleepState()), maxSeconds: NOSLEEP_MAX_SECONDS });
620
648
  }
621
649
  // POST /api/nosleep { seconds } — hold this Mac awake, lid closed, for a bounded window.
622
650
  // Only works once `conductor-remote nosleep setup` has installed the scoped sudoers
623
651
  // rule; without it there is no way for a TTY-less daemon to reach root, and the
624
652
  // response says so rather than failing vaguely.
625
- if (req.method === 'POST' && pathname === '/api/nosleep') {
653
+ if (isRoute(routes.armNoSleep, req.method, pathname)) {
626
654
  const body = JSON.parse((await readBody(req)) || '{}');
627
655
  const seconds = Number(body.seconds);
628
656
  // Whole seconds, not just "> 0": the helper reads 0 as "until killed", and 0.4
@@ -633,7 +661,7 @@ const server = http.createServer(async (req, res) => {
633
661
  return json(req, res, result.ok ? 200 : result.state.available ? 502 : 409, result);
634
662
  }
635
663
  // DELETE /api/nosleep — let it sleep again now, rather than at the window's end
636
- if (req.method === 'DELETE' && pathname === '/api/nosleep') {
664
+ if (isRoute(routes.disarmNoSleep, req.method, pathname)) {
637
665
  const result = await disarmNoSleep();
638
666
  return json(req, res, result.ok ? 200 : result.state.available ? 502 : 409, result);
639
667
  }
@@ -641,7 +669,7 @@ const server = http.createServer(async (req, res) => {
641
669
  // without reaching the Mac. Default is this process's captured console (ordered, timestamped);
642
670
  // `file` tails the daemon's stdout/stderr on disk, which is the only place the *previous*
643
671
  // process's crash survives. Everything is redacted: the startup banner prints the token.
644
- if (req.method === 'GET' && pathname === '/api/logs') {
672
+ if (isRoute(routes.logs, req.method, pathname)) {
645
673
  const file = url.searchParams.get('file');
646
674
  if (file && !LOG_FILE_NAMES.includes(file)) {
647
675
  return json(req, res, 404, { error: `unknown log file ${file}`, files: LOG_FILE_NAMES });
@@ -667,13 +695,13 @@ const server = http.createServer(async (req, res) => {
667
695
  });
668
696
  }
669
697
  // GET /api/push — the VAPID public key the phone subscribes with, plus who's already subscribed
670
- if (req.method === 'GET' && pathname === '/api/push') {
698
+ if (isRoute(routes.push, req.method, pathname)) {
671
699
  return json(req, res, 200, pushConfig());
672
700
  }
673
701
  // POST /api/push/subscribe { subscription, label? } — register (or refresh) this device.
674
702
  // Idempotent by endpoint: the app re-sends on every load, which is what heals a relay that
675
703
  // lost its store, or a subscription the browser silently renewed.
676
- if (req.method === 'POST' && pathname === '/api/push/subscribe') {
704
+ if (isRoute(routes.pushSubscribe, req.method, pathname)) {
677
705
  const body = JSON.parse((await readBody(req)) || '{}');
678
706
  const sub = body.subscription;
679
707
  if (!sub?.endpoint || !sub.keys?.p256dh || !sub.keys.auth) {
@@ -686,14 +714,14 @@ const server = http.createServer(async (req, res) => {
686
714
  return json(req, res, 200, { ok: true, ...registered });
687
715
  }
688
716
  // POST /api/push/unsubscribe { endpoint } — the phone turned notifications off
689
- if (req.method === 'POST' && pathname === '/api/push/unsubscribe') {
717
+ if (isRoute(routes.pushUnsubscribe, req.method, pathname)) {
690
718
  const body = JSON.parse((await readBody(req)) || '{}');
691
719
  if (!body.endpoint)
692
720
  return json(req, res, 400, { error: 'need the endpoint' });
693
721
  return json(req, res, 200, { ok: unsubscribeDevice(body.endpoint), devices: pushConfig().devices });
694
722
  }
695
723
  // 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') {
724
+ if (isRoute(routes.pushTest, req.method, pathname)) {
697
725
  const body = JSON.parse((await readBody(req)) || '{}');
698
726
  if (!body.id)
699
727
  return json(req, res, 400, { error: 'need the device id' });
@@ -708,7 +736,7 @@ const server = http.createServer(async (req, res) => {
708
736
  return json(req, res, result.ok ? 200 : 502, result);
709
737
  }
710
738
  // POST /api/workspaces { repo, prompt, send? } — create a workspace via Conductor's deep link
711
- if (req.method === 'POST' && pathname === '/api/workspaces') {
739
+ if (isRoute(routes.createWorkspace, req.method, pathname)) {
712
740
  const body = JSON.parse((await readBody(req)) || '{}');
713
741
  // The prompt is optional — a bare `path=` opens an empty workspace, like
714
742
  // Conductor's own New workspace — but *something* has to say where it goes.
@@ -759,9 +787,9 @@ const server = http.createServer(async (req, res) => {
759
787
  });
760
788
  }
761
789
  // 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]));
790
+ const repo = routeParam(routes.repoIcon, req.method, pathname);
791
+ if (repo) {
792
+ const icon = reads.resolveRepoIcon(repo);
765
793
  if (!icon)
766
794
  return json(req, res, 404, { error: 'no icon' });
767
795
  return void fs.readFile(icon.path, (err, data) => {
@@ -773,32 +801,26 @@ const server = http.createServer(async (req, res) => {
773
801
  });
774
802
  }
775
803
  // 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])) });
804
+ const listSessionsIn = routeParam(routes.sessions, req.method, pathname);
805
+ if (listSessionsIn) {
806
+ return json(req, res, 200, { sessions: reads.listSessions(listSessionsIn) });
779
807
  }
780
808
  // 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]);
809
+ const newChatIn = routeParam(routes.newChat, req.method, pathname);
810
+ if (newChatIn) {
811
+ const workspaceId = newChatIn;
783
812
  const ws = reads.getWorkspace(workspaceId);
784
813
  if (!ws)
785
814
  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 });
815
+ const opened = await openChat(ws);
816
+ if ('error' in opened)
817
+ return json(req, res, 502, opened.result);
818
+ return json(req, res, 200, { ok: true, sessionId: opened.sessionId });
797
819
  }
798
820
  // 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]));
821
+ const diffOf = routeParam(routes.diff, req.method, pathname);
822
+ if (diffOf) {
823
+ const ws = reads.getWorkspace(diffOf);
802
824
  if (!ws)
803
825
  return json(req, res, 404, { error: 'workspace not found' });
804
826
  if (!ws.worktree)
@@ -807,9 +829,9 @@ const server = http.createServer(async (req, res) => {
807
829
  return json(req, res, 200, diff);
808
830
  }
809
831
  // 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]));
832
+ const mergeOf = routeParam(routes.merge, req.method, pathname);
833
+ if (mergeOf) {
834
+ const ws = reads.getWorkspace(mergeOf);
813
835
  if (!ws)
814
836
  return json(req, res, 404, { error: 'workspace not found' });
815
837
  const result = await mergePr(ws);
@@ -819,9 +841,9 @@ const server = http.createServer(async (req, res) => {
819
841
  // Conductor derives that status from a PR it sometimes never links (a PR merged inside its
820
842
  // poll window is invisible to it afterwards), which strands finished work in "In progress"
821
843
  // 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]);
844
+ const statusOf = routeParam(routes.workspaceStatus, req.method, pathname);
845
+ if (statusOf) {
846
+ const workspaceId = statusOf;
825
847
  const body = JSON.parse((await readBody(req)) || '{}');
826
848
  const status = body.status ?? '';
827
849
  if (!WORKSPACE_STATUS_LABELS[status]) {
@@ -853,15 +875,15 @@ const server = http.createServer(async (req, res) => {
853
875
  return json(req, res, 200, { ok: true, workspace: reads.getWorkspace(workspaceId) });
854
876
  }
855
877
  // GET /api/sessions/:id/messages?after=<rowid>
856
- m = pathname.match(/^\/api\/sessions\/([^/]+)\/messages$/);
857
- if (req.method === 'GET' && m) {
878
+ const messagesOf = routeParam(routes.messages, req.method, pathname);
879
+ if (messagesOf) {
858
880
  const after = Number(url.searchParams.get('after') ?? 0);
859
- return json(req, res, 200, reads.getMessages(decodeURIComponent(m[1]), Number.isFinite(after) ? after : 0));
881
+ return json(req, res, 200, reads.getMessages(messagesOf, Number.isFinite(after) ? after : 0));
860
882
  }
861
883
  // 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]);
884
+ const modelsOf = routeParam(routes.models, req.method, pathname);
885
+ if (modelsOf) {
886
+ const sessionId = modelsOf;
865
887
  const ws = reads.getWorkspace(url.searchParams.get('workspaceId') ?? '');
866
888
  if (!ws)
867
889
  return json(req, res, 404, { error: 'workspace for session not found' });
@@ -873,9 +895,9 @@ const server = http.createServer(async (req, res) => {
873
895
  }
874
896
  // POST /api/sessions/:id/agent { effort?, plan?, fast?, model? }
875
897
  // 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]);
898
+ const agentOf = routeParam(routes.agent, req.method, pathname);
899
+ if (agentOf) {
900
+ const sessionId = agentOf;
879
901
  const body = JSON.parse((await readBody(req)) || '{}');
880
902
  if (body.effort && !EFFORT_LABELS[body.effort]) {
881
903
  return json(req, res, 400, { error: `effort must be one of ${Object.keys(EFFORT_LABELS).join(', ')}` });
@@ -891,9 +913,9 @@ const server = http.createServer(async (req, res) => {
891
913
  return json(req, res, 200, { ok: true, session: reads.listSessions(ws.id).find(s => s.id === sessionId) });
892
914
  }
893
915
  // 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]);
916
+ const stopOf = routeParam(routes.stop, req.method, pathname);
917
+ if (stopOf) {
918
+ const sessionId = stopOf;
897
919
  const body = JSON.parse((await readBody(req)) || '{}');
898
920
  const ws = body.workspaceId
899
921
  ? reads.getWorkspace(body.workspaceId)
@@ -939,9 +961,9 @@ const server = http.createServer(async (req, res) => {
939
961
  // POST /api/sessions/:id/prompt { text, agent? } — agent is the phone's staged
940
962
  // settings patch, applied before the prompt so the two can't come apart (and so
941
963
  // 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]);
964
+ const promptTo = routeParam(routes.sendPrompt, req.method, pathname);
965
+ if (promptTo) {
966
+ const sessionId = promptTo;
945
967
  const body = JSON.parse((await readBody(req)) || '{}');
946
968
  const text = (body.text ?? '').trim();
947
969
  if (!text)
@@ -998,18 +1020,98 @@ const server = http.createServer(async (req, res) => {
998
1020
  }
999
1021
  return json(req, res, 502, result);
1000
1022
  }
1023
+ // POST /api/sessions/:id/split { prompt?, includeThinking?, includeTools? }
1024
+ //
1025
+ // Conductor's own "Fork to new tab" resumes the agent's real session. This copies
1026
+ // the conversation instead, as a Conductor attachment, which is the cut that
1027
+ // survives being read by a *different* agent: prose and reasoning, no tool churn.
1028
+ // Two reasons it exists at all. A tangent asked inside a running chat leaves three
1029
+ // conversations interleaved in one tab, which reads badly for everyone afterwards;
1030
+ // and Conductor's fork lives on a hover menu over one message, which an agent
1031
+ // cannot reach and which the relay would have to find by walking a transcript that
1032
+ // gets more expensive the longer the chat is.
1033
+ //
1034
+ // It stops before sending. The composed prompt goes out through the ordinary send
1035
+ // route so it inherits the retry loop, the transcript confirm and the parked queue
1036
+ // — and because ⌘T plus a send is two UI turns, which together outlast any caller's
1037
+ // budget (28s + 55s against the MCP client's 75s).
1038
+ const splitFrom = routeParam(routes.splitChat, req.method, pathname);
1039
+ if (splitFrom) {
1040
+ const sessionId = splitFrom;
1041
+ const body = JSON.parse((await readBody(req)) || '{}');
1042
+ // `active_session_id` is how every other route resolves this, and it would only
1043
+ // ever find the tab on screen. Splitting a chat you are not looking at is the
1044
+ // normal case here, so the session's own column decides.
1045
+ const workspaceId = body.workspaceId ?? reads.sessionWorkspaceId(sessionId);
1046
+ const ws = workspaceId ? reads.getWorkspace(workspaceId) : null;
1047
+ if (!ws)
1048
+ return json(req, res, 404, { error: 'workspace for session not found' });
1049
+ if (!ws.worktree)
1050
+ return json(req, res, 409, { error: 'worktree path unresolved' });
1051
+ const source = reads.listSessions(ws.id).find(s => s.id === sessionId);
1052
+ if (!source)
1053
+ return json(req, res, 404, { error: 'chat not found in that workspace' });
1054
+ const format = { thinking: body.includeThinking !== false, tools: body.includeTools === true };
1055
+ const { entries } = reads.getMessages(sessionId);
1056
+ const rendered = renderTranscript(entries, format);
1057
+ if (!rendered.kept)
1058
+ return json(req, res, 409, { error: 'that chat has nothing to copy yet' });
1059
+ // Conductor's own name for a copied transcript, so the chip reads the same as one
1060
+ // saved by hand. The header states the cut, because a transcript that silently
1061
+ // drops half a chat is worse than one that admits to it.
1062
+ const title = source.title?.trim() || 'chat';
1063
+ const carried = [`thinking ${format.thinking ? 'included' : 'omitted'}`];
1064
+ carried.push(`tool calls ${format.tools ? 'included' : 'omitted'}`);
1065
+ const header = [
1066
+ `# Transcript of ${title}`,
1067
+ '',
1068
+ `${[ws.repo_name, ws.branch].filter(Boolean).join(' · ')}`,
1069
+ `Copied from the Conductor chat \`${sessionId}\` by conductor-remote. ${carried.join(', ')}.`,
1070
+ '',
1071
+ ''
1072
+ ].join('\n');
1073
+ const attachment = writeAttachment(ws.worktree, `Transcript of ${title}.md`, header + rendered.text);
1074
+ const opened = await openChat(ws);
1075
+ if ('error' in opened) {
1076
+ return json(req, res, 502, { ...opened.result, attachment: { ...attachment, ...rendered } });
1077
+ }
1078
+ // Both forms on purpose: the token is what Conductor turns into a chip, and the
1079
+ // sentence is what still works if it does not. Nothing here may depend on which.
1080
+ const prompt = (body.prompt ?? '').trim();
1081
+ const text = [
1082
+ attachment.token,
1083
+ `(the chat this was split off from — read \`${attachment.relPath}\` first)`,
1084
+ '',
1085
+ prompt
1086
+ ]
1087
+ .join('\n')
1088
+ .trim();
1089
+ return json(req, res, 200, {
1090
+ ok: true,
1091
+ sessionId: opened.sessionId,
1092
+ workspaceId: ws.id,
1093
+ text,
1094
+ attachment: {
1095
+ name: attachment.name,
1096
+ path: attachment.relPath,
1097
+ bytes: attachment.bytes,
1098
+ kept: rendered.kept,
1099
+ elided: rendered.elided
1100
+ }
1101
+ });
1102
+ }
1001
1103
  // 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]);
1104
+ const forgetFirst = routeParam(routes.dismissFirstPrompt, req.method, pathname);
1105
+ if (forgetFirst) {
1106
+ const workspaceId = forgetFirst;
1005
1107
  if (!firstPrompts.forget(workspaceId))
1006
1108
  return json(req, res, 404, { error: 'no pending prompt' });
1007
1109
  return json(req, res, 200, { ok: true });
1008
1110
  }
1009
1111
  // 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]);
1112
+ const forgetParked = routeParam(routes.dismissParkedPrompt, req.method, pathname);
1113
+ if (forgetParked) {
1114
+ const sessionId = forgetParked;
1013
1115
  if (!parkedPrompts.forgetSession(sessionId))
1014
1116
  return json(req, res, 404, { error: 'no parked prompt' });
1015
1117
  return json(req, res, 200, { ok: true });
@@ -117,3 +117,83 @@ export function parseMessage(row, worktree = null) {
117
117
  flush();
118
118
  return entries;
119
119
  }
120
+ const HEADINGS = {
121
+ user: 'User',
122
+ assistant: 'Assistant',
123
+ thinking: 'Thinking',
124
+ tool: 'Tools',
125
+ system: 'System'
126
+ };
127
+ /** One tool row per line, the shape `read_chat` prints: what it did, then what it did it to. */
128
+ function toolLine(e) {
129
+ if (e.error)
130
+ return `- [error] ${e.text}`;
131
+ return `- [${e.tool ?? 'tool'}] ${e.text}${e.detail ? ` — \`${e.detail}\`` : ''}`;
132
+ }
133
+ function plural(n, one) {
134
+ return `${n} ${one}${n === 1 ? '' : 's'}`;
135
+ }
136
+ /**
137
+ * A chat as markdown, in Conductor's own transcript layout.
138
+ *
139
+ * The layout is copied from the files Conductor writes (`Transcript of <chat>.md`):
140
+ * an `##` heading per role, prose verbatim under it, and an elision marker for what
141
+ * was dropped. The heading comes *before* the marker — a run of tool calls between a
142
+ * prompt and its answer prints as `## Assistant`, then the marker, then the reply —
143
+ * which is what makes the result read like Conductor's own file rather than a log.
144
+ *
145
+ * The marker says what kind of thing went missing rather than only how many, because
146
+ * this render is configurable and Conductor's is not: "12 tool calls elided" tells
147
+ * you a flag was off, where a bare count reads as noise nobody wanted.
148
+ *
149
+ * `system` rows are always kept. They are rare, short, and one of them is how a
150
+ * cancelled turn ends ("aborted by user") — the single line that explains why an
151
+ * answer stops mid-thought, and dropping it would leave the next agent to guess.
152
+ */
153
+ export function renderTranscript(entries, format) {
154
+ const out = [];
155
+ const elided = { thinking: 0, tools: 0 };
156
+ const pending = { thinking: 0, tools: 0 };
157
+ let heading = null;
158
+ let kept = 0;
159
+ const flushElisions = () => {
160
+ const parts = [];
161
+ if (pending.tools)
162
+ parts.push(plural(pending.tools, 'tool call'));
163
+ if (pending.thinking)
164
+ parts.push(plural(pending.thinking, 'thinking block'));
165
+ pending.tools = 0;
166
+ pending.thinking = 0;
167
+ if (parts.length)
168
+ out.push(`[${parts.join(', ')} elided]`);
169
+ };
170
+ for (const e of entries) {
171
+ if (e.role === 'thinking' && !format.thinking) {
172
+ pending.thinking++;
173
+ elided.thinking++;
174
+ continue;
175
+ }
176
+ if (e.role === 'tool' && !format.tools) {
177
+ pending.tools++;
178
+ elided.tools++;
179
+ continue;
180
+ }
181
+ const want = HEADINGS[e.role];
182
+ if (want !== heading) {
183
+ out.push(`## ${want}`);
184
+ heading = want;
185
+ }
186
+ flushElisions();
187
+ out.push(e.role === 'tool' ? toolLine(e) : e.text);
188
+ kept++;
189
+ }
190
+ // Anything dropped after the last kept entry still has to be admitted to.
191
+ flushElisions();
192
+ // Tool rows are a list, so consecutive ones share a paragraph; everything else is
193
+ // separated by a blank line, which is what makes the markdown render as prose.
194
+ const text = out
195
+ .map((line, i) => (line.startsWith('- ') && out[i + 1]?.startsWith('- ') ? `${line}\n` : `${line}\n\n`))
196
+ .join('')
197
+ .trim();
198
+ return { text: `${text}\n`, kept, elided };
199
+ }
@@ -638,6 +638,13 @@ export async function createWorkspace(prompt, repoPath) {
638
638
  * Open a new chat in the target workspace — Conductor's "New chat, same files"
639
639
  * (Cmd+T). Focuses the workspace first (its own link, see `workspaceLink`), then
640
640
  * Cmd+T; the caller detects the freshly-created session id from the DB.
641
+ *
642
+ * The pane is asserted before the keystroke for the same reason a send asserts before
643
+ * typing: `focusWorkspace` confirms every route it takes except its last one, the
644
+ * palette, and Cmd+T against an unconfirmed pane opens a tab in someone else's
645
+ * workspace. Nothing catches that afterwards — the caller looks for the new session in
646
+ * *this* workspace's tab list, so a stray tab reads as "the id could not be read back"
647
+ * while sitting in a conversation nobody asked to change.
641
648
  */
642
649
  export async function newChat(workspace) {
643
650
  if (!focusQuery(workspace))
@@ -647,6 +654,9 @@ ${CONDUCTOR_HANDLERS}
647
654
 
648
655
  my activateConductor()
649
656
  my focusWorkspace()
657
+ set strips to my tabGroups()
658
+ if (count of strips) is 0 then error "couldn't find the chat pane to open a tab in"
659
+ my assertWorkspace(item 1 of strips)
650
660
  tell application "System Events"
651
661
  keystroke "t" using {command down}
652
662
  end tell`.trim();