conductor-remote 1.9.0 → 1.9.1
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/bin/cli.js +21 -6
- package/dist-node/scripts/qr.js +400 -0
- package/dist-node/scripts/service.js +467 -0
- package/dist-node/src/autoupdate.js +160 -0
- package/dist-node/src/config.js +60 -0
- package/dist-node/src/db.js +36 -0
- package/dist-node/src/git.js +98 -0
- package/dist-node/src/icons.js +56 -0
- package/dist-node/src/pkg-root.js +20 -0
- package/dist-node/src/reads.js +108 -0
- package/dist-node/src/server.js +188 -0
- package/dist-node/src/sidecar.js +186 -0
- package/dist-node/src/transcript.js +83 -0
- package/dist-node/src/writes.js +144 -0
- package/package.json +5 -5
- package/scripts/dev.ts +0 -75
- package/scripts/devtools-recon.js +0 -42
- package/scripts/gen-icons.ts +0 -22
- package/scripts/qr.ts +0 -393
- package/scripts/service.ts +0 -485
- package/src/autoupdate.ts +0 -179
- package/src/config.ts +0 -81
- package/src/db.ts +0 -38
- package/src/git.ts +0 -116
- package/src/icons.ts +0 -66
- package/src/reads.ts +0 -178
- package/src/server.ts +0 -194
- package/src/sidecar.ts +0 -194
- package/src/transcript.ts +0 -111
- package/src/writes.ts +0 -179
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
/**
|
|
3
|
+
* Read-only handle to Conductor's SQLite DB.
|
|
4
|
+
*
|
|
5
|
+
* The desktop app holds the same file open in WAL mode; a second read-only
|
|
6
|
+
* connection sees every committed write without blocking the app. We never
|
|
7
|
+
* write through this handle — writes go through the actuator (see writes.ts).
|
|
8
|
+
*/
|
|
9
|
+
export class ConductorDb {
|
|
10
|
+
dbPath;
|
|
11
|
+
db;
|
|
12
|
+
constructor(dbPath) {
|
|
13
|
+
this.dbPath = dbPath;
|
|
14
|
+
this.db = this.open();
|
|
15
|
+
}
|
|
16
|
+
open() {
|
|
17
|
+
const db = new DatabaseSync(this.dbPath, { readOnly: true });
|
|
18
|
+
try {
|
|
19
|
+
db.exec('PRAGMA busy_timeout = 2000');
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// read-only connections may reject some pragmas; harmless
|
|
23
|
+
}
|
|
24
|
+
return db;
|
|
25
|
+
}
|
|
26
|
+
query(sql, params = []) {
|
|
27
|
+
try {
|
|
28
|
+
return this.db.prepare(sql).all(...params);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// If the DB file was swapped underneath us (app update), reopen once.
|
|
32
|
+
this.db = this.open();
|
|
33
|
+
return this.db.prepare(sql).all(...params);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const exec = promisify(execFile);
|
|
4
|
+
const MAX_PATCH_BYTES = 400_000;
|
|
5
|
+
async function git(cwd, args) {
|
|
6
|
+
const { stdout } = await exec('git', ['-C', cwd, ...args], {
|
|
7
|
+
encoding: 'utf8',
|
|
8
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
9
|
+
timeout: 15000
|
|
10
|
+
});
|
|
11
|
+
return stdout;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Patch for untracked files. `git diff` ignores them, but a reviewer wants to
|
|
15
|
+
* see new files — so we synthesize a "new file" diff via `--no-index` against
|
|
16
|
+
* /dev/null. This never touches the index (no `add -N`), so the live worktree
|
|
17
|
+
* the agent is using is left untouched.
|
|
18
|
+
*/
|
|
19
|
+
async function untrackedDiff(cwd) {
|
|
20
|
+
let listing = '';
|
|
21
|
+
try {
|
|
22
|
+
listing = await git(cwd, ['ls-files', '--others', '--exclude-standard']);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return { files: [], patch: '' };
|
|
26
|
+
}
|
|
27
|
+
const paths = listing.split('\n').filter(Boolean);
|
|
28
|
+
const files = [];
|
|
29
|
+
const patches = [];
|
|
30
|
+
for (const p of paths) {
|
|
31
|
+
try {
|
|
32
|
+
// --no-index exits 1 when files differ, so read stdout from the error.
|
|
33
|
+
await exec('git', ['-C', cwd, 'diff', '--no-index', '--no-color', '--', '/dev/null', p], {
|
|
34
|
+
encoding: 'utf8',
|
|
35
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
36
|
+
timeout: 10000
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
const out = err.stdout ?? '';
|
|
41
|
+
if (!out)
|
|
42
|
+
continue;
|
|
43
|
+
const added = out.split('\n').filter(l => l.startsWith('+') && !l.startsWith('+++')).length;
|
|
44
|
+
files.push({ path: p, added, removed: 0 });
|
|
45
|
+
patches.push(out);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { files, patch: patches.join('') };
|
|
49
|
+
}
|
|
50
|
+
/** Resolve the base ref, preferring the remote-tracking form if it exists. */
|
|
51
|
+
async function resolveBase(cwd, base) {
|
|
52
|
+
for (const ref of [`origin/${base}`, base]) {
|
|
53
|
+
try {
|
|
54
|
+
await git(cwd, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
|
|
55
|
+
return ref;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// try next
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return base;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Everything the workspace changed relative to its target branch — committed
|
|
65
|
+
* plus uncommitted — which is what a reviewer wants to see. Computed straight
|
|
66
|
+
* from the worktree, so it's independent of Conductor entirely.
|
|
67
|
+
*/
|
|
68
|
+
export async function workspaceDiff(worktree, base) {
|
|
69
|
+
const ref = await resolveBase(worktree, base);
|
|
70
|
+
let mergeBase = null;
|
|
71
|
+
try {
|
|
72
|
+
mergeBase = (await git(worktree, ['merge-base', ref, 'HEAD'])).trim();
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
mergeBase = null;
|
|
76
|
+
}
|
|
77
|
+
const against = mergeBase ?? ref;
|
|
78
|
+
const numstat = await git(worktree, ['diff', '--numstat', against]).catch(() => '');
|
|
79
|
+
const files = numstat
|
|
80
|
+
.split('\n')
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.map(line => {
|
|
83
|
+
const [added, removed, ...rest] = line.split('\t');
|
|
84
|
+
return {
|
|
85
|
+
path: rest.join('\t'),
|
|
86
|
+
added: added === '-' ? 0 : Number(added),
|
|
87
|
+
removed: removed === '-' ? 0 : Number(removed)
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
const trackedPatch = await git(worktree, ['diff', against]).catch(() => '');
|
|
91
|
+
const untracked = await untrackedDiff(worktree);
|
|
92
|
+
files.push(...untracked.files);
|
|
93
|
+
let patch = trackedPatch + untracked.patch;
|
|
94
|
+
const truncated = patch.length > MAX_PATCH_BYTES;
|
|
95
|
+
if (truncated)
|
|
96
|
+
patch = `${patch.slice(0, MAX_PATCH_BYTES)}\n\n… diff truncated (${patch.length} bytes) …`;
|
|
97
|
+
return { base: ref, mergeBase, files, patch, truncated };
|
|
98
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Repo-icon resolution, mirroring Conductor's own logic so the phone sidebar shows
|
|
5
|
+
* the same avatar as the desktop app. Conductor looks for a known icon filename in
|
|
6
|
+
* the repository root and uses the first match — see
|
|
7
|
+
* https://www.conductor.build/docs/faq#where-does-conductor-get-the-repo-icon
|
|
8
|
+
*
|
|
9
|
+
* This reads from the repo's root checkout (the shared, canonical tree), not a
|
|
10
|
+
* per-workspace worktree, so every workspace of a repo resolves to one icon.
|
|
11
|
+
*/
|
|
12
|
+
const ICON_CANDIDATES = [
|
|
13
|
+
'public/apple-touch-icon.png',
|
|
14
|
+
'apple-touch-icon.png',
|
|
15
|
+
'public/favicon.svg',
|
|
16
|
+
'favicon.svg',
|
|
17
|
+
'public/favicon.png',
|
|
18
|
+
'public/icon.png',
|
|
19
|
+
'public/logo.png',
|
|
20
|
+
'favicon.png',
|
|
21
|
+
'app/icon.png',
|
|
22
|
+
'src/app/icon.png',
|
|
23
|
+
'public/favicon.ico',
|
|
24
|
+
'favicon.ico',
|
|
25
|
+
'app/favicon.ico',
|
|
26
|
+
'static/favicon.ico',
|
|
27
|
+
'src-tauri/icons/icon.png',
|
|
28
|
+
'assets/icon.png',
|
|
29
|
+
'src/assets/icon.png'
|
|
30
|
+
];
|
|
31
|
+
const CONTENT_TYPES = {
|
|
32
|
+
'.png': 'image/png',
|
|
33
|
+
'.svg': 'image/svg+xml',
|
|
34
|
+
'.ico': 'image/x-icon'
|
|
35
|
+
};
|
|
36
|
+
// Icons rarely change; a short TTL keeps the 2.5s state poll from stat-storming the
|
|
37
|
+
// disk while still picking up a freshly-added icon within a tick or two.
|
|
38
|
+
const TTL_MS = 30_000;
|
|
39
|
+
const cache = new Map();
|
|
40
|
+
/** First matching icon under `repoRoot`, or null if the repo has none. Cached per root. */
|
|
41
|
+
export function resolveRepoIcon(repoRoot) {
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
const hit = cache.get(repoRoot);
|
|
44
|
+
if (hit && now - hit.at < TTL_MS)
|
|
45
|
+
return hit.icon;
|
|
46
|
+
let icon = null;
|
|
47
|
+
for (const rel of ICON_CANDIDATES) {
|
|
48
|
+
const abs = path.join(repoRoot, rel);
|
|
49
|
+
if (fs.existsSync(abs)) {
|
|
50
|
+
icon = { path: abs, contentType: CONTENT_TYPES[path.extname(abs).toLowerCase()] ?? 'application/octet-stream' };
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
cache.set(repoRoot, { at: now, icon });
|
|
55
|
+
return icon;
|
|
56
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* The package root — the directory holding package.json (and thus bin/ and the Vite dist/). Walk up from
|
|
5
|
+
* `fromDir` because these modules run at two depths: src/ in a dev checkout (Node type-stripping) and
|
|
6
|
+
* dist-node/src/ in the published tarball (compiled by tsconfig.build.json). Anchoring on the nearest
|
|
7
|
+
* package.json resolves both to the same real root, and conductor-remote's own package.json is always the
|
|
8
|
+
* first one found, so it never escapes into a parent node_modules.
|
|
9
|
+
*/
|
|
10
|
+
export function packageRoot(fromDir) {
|
|
11
|
+
let dir = fromDir;
|
|
12
|
+
for (;;) {
|
|
13
|
+
if (fs.existsSync(path.join(dir, 'package.json')))
|
|
14
|
+
return dir;
|
|
15
|
+
const parent = path.dirname(dir);
|
|
16
|
+
if (parent === dir)
|
|
17
|
+
return fromDir;
|
|
18
|
+
dir = parent;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { resolveRepoIcon } from "./icons.js";
|
|
5
|
+
import { parseMessage } from "./transcript.js";
|
|
6
|
+
const worktreeCache = new Map();
|
|
7
|
+
/**
|
|
8
|
+
* Resolve a workspace's worktree path. Conductor lays worktrees out as
|
|
9
|
+
* `<workspacesRoot>/<repoName>/<directoryName>`, but we verify against
|
|
10
|
+
* `git worktree list` (matched by branch) so a layout change can't silently
|
|
11
|
+
* point us at the wrong tree.
|
|
12
|
+
*/
|
|
13
|
+
function resolveWorktree(workspacesRoot, repoName, directoryName, branch, repoRoot) {
|
|
14
|
+
if (repoName && directoryName) {
|
|
15
|
+
const guess = path.join(workspacesRoot, repoName, directoryName);
|
|
16
|
+
if (fs.existsSync(path.join(guess, '.git')))
|
|
17
|
+
return guess;
|
|
18
|
+
}
|
|
19
|
+
if (!(repoRoot && branch))
|
|
20
|
+
return null;
|
|
21
|
+
const cacheKey = repoRoot;
|
|
22
|
+
let listing = worktreeCache.get(cacheKey);
|
|
23
|
+
if (listing === undefined) {
|
|
24
|
+
try {
|
|
25
|
+
listing = execFileSync('git', ['-C', repoRoot, 'worktree', 'list', '--porcelain'], {
|
|
26
|
+
encoding: 'utf8',
|
|
27
|
+
timeout: 5000
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
listing = null;
|
|
32
|
+
}
|
|
33
|
+
worktreeCache.set(cacheKey, listing);
|
|
34
|
+
}
|
|
35
|
+
if (!listing)
|
|
36
|
+
return null;
|
|
37
|
+
// Porcelain: blocks of "worktree <path>" / "branch refs/heads/<name>"
|
|
38
|
+
const blocks = listing.split('\n\n');
|
|
39
|
+
for (const block of blocks) {
|
|
40
|
+
if (block.includes(`refs/heads/${branch}`)) {
|
|
41
|
+
const m = block.match(/^worktree (.+)$/m);
|
|
42
|
+
if (m)
|
|
43
|
+
return m[1];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
export class Reads {
|
|
49
|
+
db;
|
|
50
|
+
workspacesRoot;
|
|
51
|
+
constructor(db, workspacesRoot) {
|
|
52
|
+
this.db = db;
|
|
53
|
+
this.workspacesRoot = workspacesRoot;
|
|
54
|
+
}
|
|
55
|
+
listWorkspaces() {
|
|
56
|
+
const rows = this.db.query(`SELECT w.id, w.directory_name, w.workspace_name, w.branch, w.derived_status, w.manual_status,
|
|
57
|
+
w.created_at, w.updated_at, w.unread, w.pinned_at, w.active_session_id, w.intended_target_branch,
|
|
58
|
+
r.name AS repo_name, r.root_path AS repo_root, r.default_branch AS default_branch,
|
|
59
|
+
s.status AS session_status, s.title AS session_title, s.model AS model,
|
|
60
|
+
s.context_used_percent AS context_used_percent
|
|
61
|
+
FROM workspaces w
|
|
62
|
+
LEFT JOIN repos r ON r.id = w.repository_id
|
|
63
|
+
LEFT JOIN sessions s ON s.id = w.active_session_id
|
|
64
|
+
WHERE w.state = 'ready'
|
|
65
|
+
ORDER BY (w.pinned_at IS NULL), w.updated_at DESC`);
|
|
66
|
+
return rows.map(r => ({
|
|
67
|
+
...r,
|
|
68
|
+
worktree: resolveWorktree(this.workspacesRoot, r.repo_name, r.directory_name, r.branch, r.repo_root),
|
|
69
|
+
baseBranch: r.intended_target_branch || r.default_branch || 'main',
|
|
70
|
+
hasRepoIcon: !!(r.repo_root && resolveRepoIcon(r.repo_root))
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
getWorkspace(id) {
|
|
74
|
+
return this.listWorkspaces().find(w => w.id === id) ?? null;
|
|
75
|
+
}
|
|
76
|
+
/** Resolve a repo's icon by its name (the sidebar avatar) — null if the repo or icon is unknown. */
|
|
77
|
+
resolveRepoIcon(repoName) {
|
|
78
|
+
const rows = this.db.query('SELECT root_path FROM repos WHERE name = ? LIMIT 1', [
|
|
79
|
+
repoName
|
|
80
|
+
]);
|
|
81
|
+
const root = rows[0]?.root_path;
|
|
82
|
+
return root ? resolveRepoIcon(root) : null;
|
|
83
|
+
}
|
|
84
|
+
listSessions(workspaceId) {
|
|
85
|
+
// created_at ASC keeps tab order stable (matches the desktop app) instead of jumping on activity.
|
|
86
|
+
return this.db.query(`SELECT id, status, title, model, permission_mode, context_used_percent, unread_count,
|
|
87
|
+
created_at, updated_at, last_user_message_at
|
|
88
|
+
FROM sessions
|
|
89
|
+
WHERE workspace_id = ? AND COALESCE(is_hidden, 0) = 0
|
|
90
|
+
ORDER BY created_at ASC`, [workspaceId]);
|
|
91
|
+
}
|
|
92
|
+
/** Incremental transcript fetch. `afterRowid` is the cursor from a prior call. */
|
|
93
|
+
getMessages(sessionId, afterRowid = 0) {
|
|
94
|
+
const rows = this.db.query(`SELECT rowid, id, role, content, full_message, created_at, sent_at, queue_order
|
|
95
|
+
FROM session_messages
|
|
96
|
+
WHERE session_id = ? AND rowid > ?
|
|
97
|
+
ORDER BY rowid ASC`, [sessionId, afterRowid]);
|
|
98
|
+
const entries = [];
|
|
99
|
+
let cursor = afterRowid;
|
|
100
|
+
for (const row of rows) {
|
|
101
|
+
cursor = row.rowid;
|
|
102
|
+
const entry = parseMessage(row);
|
|
103
|
+
if (entry)
|
|
104
|
+
entries.push(entry);
|
|
105
|
+
}
|
|
106
|
+
return { entries, cursor };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { startAutoUpdate, updateStatus } from "./autoupdate.js";
|
|
6
|
+
import { loadConfig } from "./config.js";
|
|
7
|
+
import { ConductorDb } from "./db.js";
|
|
8
|
+
import { workspaceDiff } from "./git.js";
|
|
9
|
+
import { Reads } from "./reads.js";
|
|
10
|
+
import { describeActuator, newChat, pickActuator } from "./writes.js";
|
|
11
|
+
const cfg = loadConfig();
|
|
12
|
+
const db = new ConductorDb(cfg.dbPath);
|
|
13
|
+
const reads = new Reads(db, cfg.workspacesRoot);
|
|
14
|
+
const actuator = pickActuator(cfg.writeStrategy);
|
|
15
|
+
const MIME = {
|
|
16
|
+
'.html': 'text/html; charset=utf-8',
|
|
17
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
18
|
+
'.css': 'text/css; charset=utf-8',
|
|
19
|
+
'.json': 'application/json; charset=utf-8',
|
|
20
|
+
'.webmanifest': 'application/manifest+json; charset=utf-8',
|
|
21
|
+
'.svg': 'image/svg+xml',
|
|
22
|
+
'.png': 'image/png'
|
|
23
|
+
};
|
|
24
|
+
function json(res, status, body) {
|
|
25
|
+
const payload = JSON.stringify(body);
|
|
26
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
27
|
+
res.end(payload);
|
|
28
|
+
}
|
|
29
|
+
/** Constant-time string compare — the token is the sole internet-facing gate when exposed via Funnel. */
|
|
30
|
+
function tokenEq(candidate) {
|
|
31
|
+
if (candidate == null)
|
|
32
|
+
return false;
|
|
33
|
+
const a = Buffer.from(candidate);
|
|
34
|
+
const b = Buffer.from(cfg.token);
|
|
35
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
36
|
+
}
|
|
37
|
+
function authed(req) {
|
|
38
|
+
const auth = req.headers.authorization;
|
|
39
|
+
if (auth?.startsWith('Bearer '))
|
|
40
|
+
return tokenEq(auth.slice('Bearer '.length));
|
|
41
|
+
const url = new URL(req.url ?? '/', 'http://x');
|
|
42
|
+
return tokenEq(url.searchParams.get('token'));
|
|
43
|
+
}
|
|
44
|
+
async function readBody(req) {
|
|
45
|
+
const chunks = [];
|
|
46
|
+
for await (const c of req)
|
|
47
|
+
chunks.push(c);
|
|
48
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
49
|
+
}
|
|
50
|
+
/** Hashed Vite assets are immutable and cache-forever; the shell/SW must never go stale. */
|
|
51
|
+
function cacheControl(rel) {
|
|
52
|
+
if (rel.startsWith('assets/'))
|
|
53
|
+
return 'public, max-age=31536000, immutable';
|
|
54
|
+
return 'no-cache';
|
|
55
|
+
}
|
|
56
|
+
function serveStatic(_req, res, pathname) {
|
|
57
|
+
const rel = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, '');
|
|
58
|
+
const filePath = path.join(cfg.publicDir, rel);
|
|
59
|
+
// Contain to publicDir.
|
|
60
|
+
if (!filePath.startsWith(cfg.publicDir)) {
|
|
61
|
+
res.writeHead(403).end();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
fs.readFile(filePath, (err, data) => {
|
|
65
|
+
if (err) {
|
|
66
|
+
// SPA fallback to shell.
|
|
67
|
+
fs.readFile(path.join(cfg.publicDir, 'index.html'), (e2, shell) => {
|
|
68
|
+
if (e2)
|
|
69
|
+
return void res.writeHead(404).end('not found');
|
|
70
|
+
res.writeHead(200, { 'content-type': MIME['.html'], 'cache-control': 'no-cache' });
|
|
71
|
+
res.end(shell);
|
|
72
|
+
});
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const ext = path.extname(filePath);
|
|
76
|
+
res.writeHead(200, { 'content-type': MIME[ext] ?? 'application/octet-stream', 'cache-control': cacheControl(rel) });
|
|
77
|
+
res.end(data);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const server = http.createServer(async (req, res) => {
|
|
81
|
+
const url = new URL(req.url ?? '/', 'http://x');
|
|
82
|
+
const { pathname } = url;
|
|
83
|
+
if (!pathname.startsWith('/api/'))
|
|
84
|
+
return serveStatic(req, res, pathname);
|
|
85
|
+
// Everything under /api requires the shared secret.
|
|
86
|
+
if (!authed(req))
|
|
87
|
+
return json(res, 401, { error: 'unauthorized' });
|
|
88
|
+
try {
|
|
89
|
+
// GET /api/state — workspace list with active-session status
|
|
90
|
+
if (req.method === 'GET' && pathname === '/api/state') {
|
|
91
|
+
const update = updateStatus();
|
|
92
|
+
return json(res, 200, {
|
|
93
|
+
workspaces: reads.listWorkspaces(),
|
|
94
|
+
actuator: await describeActuator(actuator),
|
|
95
|
+
version: update.current,
|
|
96
|
+
update
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
// GET /api/repos/:name/icon — the repo's resolved sidebar icon (see src/icons.ts)
|
|
100
|
+
let m = pathname.match(/^\/api\/repos\/([^/]+)\/icon$/);
|
|
101
|
+
if (req.method === 'GET' && m) {
|
|
102
|
+
const icon = reads.resolveRepoIcon(decodeURIComponent(m[1]));
|
|
103
|
+
if (!icon)
|
|
104
|
+
return json(res, 404, { error: 'no icon' });
|
|
105
|
+
return void fs.readFile(icon.path, (err, data) => {
|
|
106
|
+
if (err)
|
|
107
|
+
return void json(res, 404, { error: 'no icon' });
|
|
108
|
+
// Cache briefly on the phone; the resolver itself refreshes within ~30s of an icon change.
|
|
109
|
+
res.writeHead(200, { 'content-type': icon.contentType, 'cache-control': 'public, max-age=300' });
|
|
110
|
+
res.end(data);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
// GET /api/workspaces/:id/sessions
|
|
114
|
+
m = pathname.match(/^\/api\/workspaces\/([^/]+)\/sessions$/);
|
|
115
|
+
if (req.method === 'GET' && m) {
|
|
116
|
+
return json(res, 200, { sessions: reads.listSessions(decodeURIComponent(m[1])) });
|
|
117
|
+
}
|
|
118
|
+
// POST /api/workspaces/:id/sessions — open a new chat (Cmd+T) in the workspace
|
|
119
|
+
if (req.method === 'POST' && m) {
|
|
120
|
+
const workspaceId = decodeURIComponent(m[1]);
|
|
121
|
+
const ws = reads.getWorkspace(workspaceId);
|
|
122
|
+
if (!ws)
|
|
123
|
+
return json(res, 404, { error: 'workspace not found' });
|
|
124
|
+
const before = new Set(reads.listSessions(workspaceId).map(s => s.id));
|
|
125
|
+
const result = await newChat(ws);
|
|
126
|
+
if (!result.ok)
|
|
127
|
+
return json(res, 502, result);
|
|
128
|
+
// The new session lands in the DB a beat after Cmd+T — poll for the fresh id.
|
|
129
|
+
let sessionId = null;
|
|
130
|
+
for (let i = 0; i < 12 && !sessionId; i++) {
|
|
131
|
+
await new Promise(r => setTimeout(r, 500));
|
|
132
|
+
sessionId = reads.listSessions(workspaceId).find(s => !before.has(s.id))?.id ?? null;
|
|
133
|
+
}
|
|
134
|
+
return json(res, 200, { ok: true, sessionId });
|
|
135
|
+
}
|
|
136
|
+
// GET /api/workspaces/:id/diff
|
|
137
|
+
m = pathname.match(/^\/api\/workspaces\/([^/]+)\/diff$/);
|
|
138
|
+
if (req.method === 'GET' && m) {
|
|
139
|
+
const ws = reads.getWorkspace(decodeURIComponent(m[1]));
|
|
140
|
+
if (!ws)
|
|
141
|
+
return json(res, 404, { error: 'workspace not found' });
|
|
142
|
+
if (!ws.worktree)
|
|
143
|
+
return json(res, 409, { error: 'worktree path unresolved' });
|
|
144
|
+
const diff = await workspaceDiff(ws.worktree, ws.baseBranch);
|
|
145
|
+
return json(res, 200, diff);
|
|
146
|
+
}
|
|
147
|
+
// GET /api/sessions/:id/messages?after=<rowid>
|
|
148
|
+
m = pathname.match(/^\/api\/sessions\/([^/]+)\/messages$/);
|
|
149
|
+
if (req.method === 'GET' && m) {
|
|
150
|
+
const after = Number(url.searchParams.get('after') ?? 0);
|
|
151
|
+
return json(res, 200, reads.getMessages(decodeURIComponent(m[1]), Number.isFinite(after) ? after : 0));
|
|
152
|
+
}
|
|
153
|
+
// POST /api/sessions/:id/prompt { text }
|
|
154
|
+
m = pathname.match(/^\/api\/sessions\/([^/]+)\/prompt$/);
|
|
155
|
+
if (req.method === 'POST' && m) {
|
|
156
|
+
const sessionId = decodeURIComponent(m[1]);
|
|
157
|
+
const body = JSON.parse((await readBody(req)) || '{}');
|
|
158
|
+
const text = (body.text ?? '').trim();
|
|
159
|
+
if (!text)
|
|
160
|
+
return json(res, 400, { error: 'empty prompt' });
|
|
161
|
+
const ws = body.workspaceId
|
|
162
|
+
? reads.getWorkspace(body.workspaceId)
|
|
163
|
+
: (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
|
|
164
|
+
if (!ws)
|
|
165
|
+
return json(res, 404, { error: 'workspace for session not found' });
|
|
166
|
+
const result = await actuator.send({ workspace: ws, sessionId }, text);
|
|
167
|
+
return json(res, result.ok ? 200 : 502, result);
|
|
168
|
+
}
|
|
169
|
+
return json(res, 404, { error: 'no route', pathname });
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
return json(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
server.listen(cfg.port, cfg.host, () => {
|
|
176
|
+
console.info([
|
|
177
|
+
'conductor-remote relay up',
|
|
178
|
+
` db: ${cfg.dbPath}`,
|
|
179
|
+
` worktrees: ${cfg.workspacesRoot}`,
|
|
180
|
+
` actuator: ${actuator.name}`,
|
|
181
|
+
` bound: ${cfg.host}:${cfg.port}`,
|
|
182
|
+
'',
|
|
183
|
+
` Local: http://${cfg.host}:${cfg.port}/#token=${cfg.token}`,
|
|
184
|
+
' Phone: fronted by `tailscale funnel`/`serve` — run `yarn service status` for the HTTPS URL'
|
|
185
|
+
].join('\n'));
|
|
186
|
+
// Keep the managed global daemon current — no-ops for dev checkouts / unmanaged runs (see autoupdate.ts).
|
|
187
|
+
startAutoUpdate();
|
|
188
|
+
});
|