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.
- package/README.md +25 -3
- package/dist/assets/index-BGOFh2vS.js +42 -0
- package/dist/assets/index-BSIBXmnj.css +1 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/src/attachments.js +80 -0
- package/dist-node/src/mcp-tools.js +313 -26
- package/dist-node/src/reads.js +33 -0
- package/dist-node/src/routes.js +98 -0
- package/dist-node/src/server.js +175 -63
- package/dist-node/src/transcript.js +80 -0
- package/dist-node/src/writes.js +18 -0
- package/package.json +4 -2
- package/dist/assets/index-C3e9RRjA.css +0 -1
- package/dist/assets/index-C_WDyBGp.js +0 -42
package/dist-node/src/server.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
763
|
-
if (
|
|
764
|
-
const icon = reads.resolveRepoIcon(
|
|
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
|
-
|
|
777
|
-
if (
|
|
778
|
-
return json(req, res, 200, { sessions: reads.listSessions(
|
|
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
|
-
|
|
782
|
-
|
|
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
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
|
|
800
|
-
if (
|
|
801
|
-
const ws = reads.getWorkspace(
|
|
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
|
-
|
|
811
|
-
if (
|
|
812
|
-
const ws = reads.getWorkspace(
|
|
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
|
-
|
|
823
|
-
if (
|
|
824
|
-
const workspaceId =
|
|
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
|
-
|
|
857
|
-
if (
|
|
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(
|
|
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
|
-
|
|
863
|
-
if (
|
|
864
|
-
const sessionId =
|
|
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
|
-
|
|
877
|
-
if (
|
|
878
|
-
const sessionId =
|
|
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
|
-
|
|
895
|
-
if (
|
|
896
|
-
const sessionId =
|
|
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
|
-
|
|
943
|
-
if (
|
|
944
|
-
const sessionId =
|
|
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
|
-
|
|
1003
|
-
if (
|
|
1004
|
-
const workspaceId =
|
|
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
|
-
|
|
1011
|
-
if (
|
|
1012
|
-
const sessionId =
|
|
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
|
+
}
|
package/dist-node/src/writes.js
CHANGED
|
@@ -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.
|
|
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": {
|