conductor-remote 1.42.3 → 1.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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) => {
@@ -772,33 +800,37 @@ const server = http.createServer(async (req, res) => {
772
800
  res.end(data);
773
801
  });
774
802
  }
803
+ // GET /api/workspaces/:id — one workspace by id, archived included. `/api/state` lists
804
+ // only the live ones, so this is what lets the phone open a chat search found in work
805
+ // that has been put away: the worktree is gone, the transcript is not.
806
+ const workspaceById = routeParam(routes.workspace, req.method, pathname);
807
+ if (workspaceById) {
808
+ const found = reads.getAnyWorkspace(workspaceById);
809
+ if (!found)
810
+ return json(req, res, 404, { error: 'workspace not found' });
811
+ return json(req, res, 200, { workspace: found });
812
+ }
775
813
  // 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])) });
814
+ const listSessionsIn = routeParam(routes.sessions, req.method, pathname);
815
+ if (listSessionsIn) {
816
+ return json(req, res, 200, { sessions: reads.listSessions(listSessionsIn) });
779
817
  }
780
818
  // 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]);
819
+ const newChatIn = routeParam(routes.newChat, req.method, pathname);
820
+ if (newChatIn) {
821
+ const workspaceId = newChatIn;
783
822
  const ws = reads.getWorkspace(workspaceId);
784
823
  if (!ws)
785
824
  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 });
825
+ const opened = await openChat(ws);
826
+ if ('error' in opened)
827
+ return json(req, res, 502, opened.result);
828
+ return json(req, res, 200, { ok: true, sessionId: opened.sessionId });
797
829
  }
798
830
  // 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]));
831
+ const diffOf = routeParam(routes.diff, req.method, pathname);
832
+ if (diffOf) {
833
+ const ws = reads.getWorkspace(diffOf);
802
834
  if (!ws)
803
835
  return json(req, res, 404, { error: 'workspace not found' });
804
836
  if (!ws.worktree)
@@ -807,9 +839,9 @@ const server = http.createServer(async (req, res) => {
807
839
  return json(req, res, 200, diff);
808
840
  }
809
841
  // 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]));
842
+ const mergeOf = routeParam(routes.merge, req.method, pathname);
843
+ if (mergeOf) {
844
+ const ws = reads.getWorkspace(mergeOf);
813
845
  if (!ws)
814
846
  return json(req, res, 404, { error: 'workspace not found' });
815
847
  const result = await mergePr(ws);
@@ -819,9 +851,9 @@ const server = http.createServer(async (req, res) => {
819
851
  // Conductor derives that status from a PR it sometimes never links (a PR merged inside its
820
852
  // poll window is invisible to it afterwards), which strands finished work in "In progress"
821
853
  // 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]);
854
+ const statusOf = routeParam(routes.workspaceStatus, req.method, pathname);
855
+ if (statusOf) {
856
+ const workspaceId = statusOf;
825
857
  const body = JSON.parse((await readBody(req)) || '{}');
826
858
  const status = body.status ?? '';
827
859
  if (!WORKSPACE_STATUS_LABELS[status]) {
@@ -853,15 +885,15 @@ const server = http.createServer(async (req, res) => {
853
885
  return json(req, res, 200, { ok: true, workspace: reads.getWorkspace(workspaceId) });
854
886
  }
855
887
  // GET /api/sessions/:id/messages?after=<rowid>
856
- m = pathname.match(/^\/api\/sessions\/([^/]+)\/messages$/);
857
- if (req.method === 'GET' && m) {
888
+ const messagesOf = routeParam(routes.messages, req.method, pathname);
889
+ if (messagesOf) {
858
890
  const after = Number(url.searchParams.get('after') ?? 0);
859
- return json(req, res, 200, reads.getMessages(decodeURIComponent(m[1]), Number.isFinite(after) ? after : 0));
891
+ return json(req, res, 200, reads.getMessages(messagesOf, Number.isFinite(after) ? after : 0));
860
892
  }
861
893
  // 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]);
894
+ const modelsOf = routeParam(routes.models, req.method, pathname);
895
+ if (modelsOf) {
896
+ const sessionId = modelsOf;
865
897
  const ws = reads.getWorkspace(url.searchParams.get('workspaceId') ?? '');
866
898
  if (!ws)
867
899
  return json(req, res, 404, { error: 'workspace for session not found' });
@@ -873,9 +905,9 @@ const server = http.createServer(async (req, res) => {
873
905
  }
874
906
  // POST /api/sessions/:id/agent { effort?, plan?, fast?, model? }
875
907
  // 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]);
908
+ const agentOf = routeParam(routes.agent, req.method, pathname);
909
+ if (agentOf) {
910
+ const sessionId = agentOf;
879
911
  const body = JSON.parse((await readBody(req)) || '{}');
880
912
  if (body.effort && !EFFORT_LABELS[body.effort]) {
881
913
  return json(req, res, 400, { error: `effort must be one of ${Object.keys(EFFORT_LABELS).join(', ')}` });
@@ -891,9 +923,9 @@ const server = http.createServer(async (req, res) => {
891
923
  return json(req, res, 200, { ok: true, session: reads.listSessions(ws.id).find(s => s.id === sessionId) });
892
924
  }
893
925
  // 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]);
926
+ const stopOf = routeParam(routes.stop, req.method, pathname);
927
+ if (stopOf) {
928
+ const sessionId = stopOf;
897
929
  const body = JSON.parse((await readBody(req)) || '{}');
898
930
  const ws = body.workspaceId
899
931
  ? reads.getWorkspace(body.workspaceId)
@@ -939,9 +971,9 @@ const server = http.createServer(async (req, res) => {
939
971
  // POST /api/sessions/:id/prompt { text, agent? } — agent is the phone's staged
940
972
  // settings patch, applied before the prompt so the two can't come apart (and so
941
973
  // 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]);
974
+ const promptTo = routeParam(routes.sendPrompt, req.method, pathname);
975
+ if (promptTo) {
976
+ const sessionId = promptTo;
945
977
  const body = JSON.parse((await readBody(req)) || '{}');
946
978
  const text = (body.text ?? '').trim();
947
979
  if (!text)
@@ -998,18 +1030,98 @@ const server = http.createServer(async (req, res) => {
998
1030
  }
999
1031
  return json(req, res, 502, result);
1000
1032
  }
1033
+ // POST /api/sessions/:id/split { prompt?, includeThinking?, includeTools? }
1034
+ //
1035
+ // Conductor's own "Fork to new tab" resumes the agent's real session. This copies
1036
+ // the conversation instead, as a Conductor attachment, which is the cut that
1037
+ // survives being read by a *different* agent: prose and reasoning, no tool churn.
1038
+ // Two reasons it exists at all. A tangent asked inside a running chat leaves three
1039
+ // conversations interleaved in one tab, which reads badly for everyone afterwards;
1040
+ // and Conductor's fork lives on a hover menu over one message, which an agent
1041
+ // cannot reach and which the relay would have to find by walking a transcript that
1042
+ // gets more expensive the longer the chat is.
1043
+ //
1044
+ // It stops before sending. The composed prompt goes out through the ordinary send
1045
+ // route so it inherits the retry loop, the transcript confirm and the parked queue
1046
+ // — and because ⌘T plus a send is two UI turns, which together outlast any caller's
1047
+ // budget (28s + 55s against the MCP client's 75s).
1048
+ const splitFrom = routeParam(routes.splitChat, req.method, pathname);
1049
+ if (splitFrom) {
1050
+ const sessionId = splitFrom;
1051
+ const body = JSON.parse((await readBody(req)) || '{}');
1052
+ // `active_session_id` is how every other route resolves this, and it would only
1053
+ // ever find the tab on screen. Splitting a chat you are not looking at is the
1054
+ // normal case here, so the session's own column decides.
1055
+ const workspaceId = body.workspaceId ?? reads.sessionWorkspaceId(sessionId);
1056
+ const ws = workspaceId ? reads.getWorkspace(workspaceId) : null;
1057
+ if (!ws)
1058
+ return json(req, res, 404, { error: 'workspace for session not found' });
1059
+ if (!ws.worktree)
1060
+ return json(req, res, 409, { error: 'worktree path unresolved' });
1061
+ const source = reads.listSessions(ws.id).find(s => s.id === sessionId);
1062
+ if (!source)
1063
+ return json(req, res, 404, { error: 'chat not found in that workspace' });
1064
+ const format = { thinking: body.includeThinking !== false, tools: body.includeTools === true };
1065
+ const { entries } = reads.getMessages(sessionId);
1066
+ const rendered = renderTranscript(entries, format);
1067
+ if (!rendered.kept)
1068
+ return json(req, res, 409, { error: 'that chat has nothing to copy yet' });
1069
+ // Conductor's own name for a copied transcript, so the chip reads the same as one
1070
+ // saved by hand. The header states the cut, because a transcript that silently
1071
+ // drops half a chat is worse than one that admits to it.
1072
+ const title = source.title?.trim() || 'chat';
1073
+ const carried = [`thinking ${format.thinking ? 'included' : 'omitted'}`];
1074
+ carried.push(`tool calls ${format.tools ? 'included' : 'omitted'}`);
1075
+ const header = [
1076
+ `# Transcript of ${title}`,
1077
+ '',
1078
+ `${[ws.repo_name, ws.branch].filter(Boolean).join(' · ')}`,
1079
+ `Copied from the Conductor chat \`${sessionId}\` by conductor-remote. ${carried.join(', ')}.`,
1080
+ '',
1081
+ ''
1082
+ ].join('\n');
1083
+ const attachment = writeAttachment(ws.worktree, `Transcript of ${title}.md`, header + rendered.text);
1084
+ const opened = await openChat(ws);
1085
+ if ('error' in opened) {
1086
+ return json(req, res, 502, { ...opened.result, attachment: { ...attachment, ...rendered } });
1087
+ }
1088
+ // Both forms on purpose: the token is what Conductor turns into a chip, and the
1089
+ // sentence is what still works if it does not. Nothing here may depend on which.
1090
+ const prompt = (body.prompt ?? '').trim();
1091
+ const text = [
1092
+ attachment.token,
1093
+ `(the chat this was split off from — read \`${attachment.relPath}\` first)`,
1094
+ '',
1095
+ prompt
1096
+ ]
1097
+ .join('\n')
1098
+ .trim();
1099
+ return json(req, res, 200, {
1100
+ ok: true,
1101
+ sessionId: opened.sessionId,
1102
+ workspaceId: ws.id,
1103
+ text,
1104
+ attachment: {
1105
+ name: attachment.name,
1106
+ path: attachment.relPath,
1107
+ bytes: attachment.bytes,
1108
+ kept: rendered.kept,
1109
+ elided: rendered.elided
1110
+ }
1111
+ });
1112
+ }
1001
1113
  // 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]);
1114
+ const forgetFirst = routeParam(routes.dismissFirstPrompt, req.method, pathname);
1115
+ if (forgetFirst) {
1116
+ const workspaceId = forgetFirst;
1005
1117
  if (!firstPrompts.forget(workspaceId))
1006
1118
  return json(req, res, 404, { error: 'no pending prompt' });
1007
1119
  return json(req, res, 200, { ok: true });
1008
1120
  }
1009
1121
  // 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]);
1122
+ const forgetParked = routeParam(routes.dismissParkedPrompt, req.method, pathname);
1123
+ if (forgetParked) {
1124
+ const sessionId = forgetParked;
1013
1125
  if (!parkedPrompts.forgetSession(sessionId))
1014
1126
  return json(req, res, 404, { error: 'no parked prompt' });
1015
1127
  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,19 @@ 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.
648
+ *
649
+ * Cmd+L ("Focus chat input") goes first for the reason `cancelAgent` does the same: a
650
+ * keystroke lands wherever focus is, and a focused terminal panel swallows this one.
651
+ * Measured live before the fix — the run reported success, `sessions` gained nothing,
652
+ * and `terminal_sessions` gained a row in this very workspace at the second the chord
653
+ * was sent. Both chords are Conductor's own, confirmed against its Cmd+/ dialog.
641
654
  */
642
655
  export async function newChat(workspace) {
643
656
  if (!focusQuery(workspace))
@@ -647,7 +660,12 @@ ${CONDUCTOR_HANDLERS}
647
660
 
648
661
  my activateConductor()
649
662
  my focusWorkspace()
663
+ set strips to my tabGroups()
664
+ if (count of strips) is 0 then error "couldn't find the chat pane to open a tab in"
665
+ my assertWorkspace(item 1 of strips)
650
666
  tell application "System Events"
667
+ keystroke "l" using {command down}
668
+ delay 0.2
651
669
  keystroke "t" using {command down}
652
670
  end tell`.trim();
653
671
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.42.3",
3
+ "version": "1.44.0",
4
4
  "type": "module",
5
5
  "packageManager": "yarn@4.15.0",
6
6
  "description": "Phone control panel for local Conductor agents. Reads ride SQLite + git; prompts ride Conductor's own dispatch path.",
@@ -50,13 +50,15 @@
50
50
  "typecheck": "tsc -p tsconfig.json",
51
51
  "lint": "biome check .",
52
52
  "fix": "biome check . --fix",
53
- "verify": "yarn typecheck && yarn lint && yarn check:imports && yarn check:applescript && yarn check:nosleep && yarn check:uilock",
53
+ "verify": "yarn typecheck && yarn lint && yarn check:imports && yarn check:routes && yarn check:attachments && yarn check:applescript && yarn check:nosleep && yarn check:uilock",
54
54
  "release": "semantic-release",
55
55
  "prepack": "yarn build && yarn build:node",
56
56
  "postinstall": "husky || true",
57
+ "check:attachments": "node scripts/check-attachments.ts",
57
58
  "check:applescript": "node scripts/check-applescript.ts",
58
59
  "check:imports": "node scripts/check-imports.ts",
59
60
  "check:nosleep": "node scripts/check-nosleep.ts",
61
+ "check:routes": "node scripts/check-routes.ts",
60
62
  "check:uilock": "node scripts/check-uilock.ts"
61
63
  },
62
64
  "devDependencies": {