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.
- package/README.md +50 -3
- package/dist/assets/index-4dAWTDxH.css +1 -0
- package/dist/assets/index-CZzXID1o.js +42 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/src/attachments.js +78 -0
- package/dist-node/src/mcp-tools.js +313 -26
- package/dist-node/src/reads.js +13 -0
- package/dist-node/src/routes.js +96 -0
- package/dist-node/src/server.js +165 -63
- package/dist-node/src/transcript.js +80 -0
- package/dist-node/src/writes.js +10 -0
- package/package.json +4 -2
- package/dist/assets/index-95IdnA_r.css +0 -1
- package/dist/assets/index-DFdIcbun.js +0 -42
|
@@ -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
|
+
}
|
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) => {
|
|
@@ -773,32 +801,26 @@ const server = http.createServer(async (req, res) => {
|
|
|
773
801
|
});
|
|
774
802
|
}
|
|
775
803
|
// GET /api/workspaces/:id/sessions
|
|
776
|
-
|
|
777
|
-
if (
|
|
778
|
-
return json(req, res, 200, { sessions: reads.listSessions(
|
|
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
|
-
|
|
782
|
-
|
|
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
|
|
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 });
|
|
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
|
-
|
|
800
|
-
if (
|
|
801
|
-
const ws = reads.getWorkspace(
|
|
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
|
-
|
|
811
|
-
if (
|
|
812
|
-
const ws = reads.getWorkspace(
|
|
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
|
-
|
|
823
|
-
if (
|
|
824
|
-
const workspaceId =
|
|
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
|
-
|
|
857
|
-
if (
|
|
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(
|
|
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
|
-
|
|
863
|
-
if (
|
|
864
|
-
const sessionId =
|
|
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
|
-
|
|
877
|
-
if (
|
|
878
|
-
const sessionId =
|
|
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
|
-
|
|
895
|
-
if (
|
|
896
|
-
const sessionId =
|
|
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
|
-
|
|
943
|
-
if (
|
|
944
|
-
const sessionId =
|
|
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
|
-
|
|
1003
|
-
if (
|
|
1004
|
-
const workspaceId =
|
|
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
|
-
|
|
1011
|
-
if (
|
|
1012
|
-
const sessionId =
|
|
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
|
+
}
|
package/dist-node/src/writes.js
CHANGED
|
@@ -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();
|