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.
@@ -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
+ });