conductor-remote 0.0.0-development

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/src/config.ts ADDED
@@ -0,0 +1,81 @@
1
+ import crypto from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import type { WriteStrategy } from './writes.ts'
6
+
7
+ const home = os.homedir()
8
+
9
+ export interface Config {
10
+ /** Path to Conductor's live SQLite state DB (read-only source of truth for reads). */
11
+ dbPath: string
12
+ /** Root under which Conductor lays out per-workspace git worktrees. */
13
+ workspacesRoot: string
14
+ /** TCP port the relay listens on. */
15
+ port: number
16
+ /** Host to bind. Loopback by default; the tailnet-facing URL is fronted by `tailscale serve`. Override with RELAY_HOST. */
17
+ host: string
18
+ /** Shared secret required on every /api/* request. Auto-generated if unset. */
19
+ token: string
20
+ /** Prompt delivery strategy: 'applescript' (default, focused session) or 'sidecar' (precise per-session IPC). */
21
+ writeStrategy: WriteStrategy
22
+ /** Directory of built PWA assets to serve. */
23
+ publicDir: string
24
+ }
25
+
26
+ /** Where a generated token is persisted so a phone's saved URL stays valid across relay restarts. */
27
+ function tokenStorePath(): string {
28
+ return path.join(home, 'Library', 'Application Support', 'conductor-remote', 'token')
29
+ }
30
+
31
+ /**
32
+ * Stable shared secret. Explicit `RELAY_TOKEN` wins; otherwise reuse a persisted token (or mint and
33
+ * persist one). Persistence matters for the daemon: a KeepAlive restart must not invalidate the URL
34
+ * the user added to their home screen.
35
+ */
36
+ function resolveToken(): string {
37
+ if (process.env.RELAY_TOKEN) return process.env.RELAY_TOKEN
38
+ const file = tokenStorePath()
39
+ try {
40
+ const existing = fs.readFileSync(file, 'utf8').trim()
41
+ if (existing) return existing
42
+ } catch {
43
+ // no persisted token yet — mint one below
44
+ }
45
+ const token = crypto.randomBytes(16).toString('hex')
46
+ try {
47
+ fs.mkdirSync(path.dirname(file), { recursive: true })
48
+ fs.writeFileSync(file, token, { mode: 0o600 })
49
+ } catch (err) {
50
+ console.warn(`⚠ could not persist token (${err instanceof Error ? err.message : err}); it will rotate on restart`)
51
+ }
52
+ return token
53
+ }
54
+
55
+ /** The relay serves the Vite build. Warn early if it hasn't been built yet. */
56
+ function resolvePublicDir(): string {
57
+ const dist = path.join(import.meta.dirname, '..', 'dist')
58
+ if (!fs.existsSync(path.join(dist, 'index.html'))) {
59
+ console.warn(
60
+ '⚠ dist/ not built — run `yarn build` (or `yarn preview`). The API works; the PWA will 404 until then.'
61
+ )
62
+ }
63
+ return dist
64
+ }
65
+
66
+ export function loadConfig(): Config {
67
+ // Bind loopback; `tailscale serve` (wired by `yarn deploy`) fronts it with a stable HTTPS tailnet URL.
68
+ const host = process.env.RELAY_HOST ?? '127.0.0.1'
69
+ const writeStrategy: WriteStrategy = process.env.WRITE_STRATEGY === 'sidecar' ? 'sidecar' : 'applescript'
70
+ return {
71
+ dbPath:
72
+ process.env.CONDUCTOR_DB ??
73
+ path.join(home, 'Library', 'Application Support', 'com.conductor.app', 'conductor.db'),
74
+ workspacesRoot: process.env.CONDUCTOR_WORKSPACES ?? path.join(home, 'conductor', 'workspaces'),
75
+ port: Number(process.env.RELAY_PORT ?? 8787),
76
+ host,
77
+ token: resolveToken(),
78
+ writeStrategy,
79
+ publicDir: resolvePublicDir()
80
+ }
81
+ }
package/src/db.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { DatabaseSync } from 'node:sqlite'
2
+
3
+ /**
4
+ * Read-only handle to Conductor's SQLite DB.
5
+ *
6
+ * The desktop app holds the same file open in WAL mode; a second read-only
7
+ * connection sees every committed write without blocking the app. We never
8
+ * write through this handle — writes go through the actuator (see writes.ts).
9
+ */
10
+ export class ConductorDb {
11
+ private readonly dbPath: string
12
+ private db: DatabaseSync
13
+
14
+ constructor(dbPath: string) {
15
+ this.dbPath = dbPath
16
+ this.db = this.open()
17
+ }
18
+
19
+ private open(): DatabaseSync {
20
+ const db = new DatabaseSync(this.dbPath, { readOnly: true })
21
+ try {
22
+ db.exec('PRAGMA busy_timeout = 2000')
23
+ } catch {
24
+ // read-only connections may reject some pragmas; harmless
25
+ }
26
+ return db
27
+ }
28
+
29
+ query<T = Record<string, unknown>>(sql: string, params: unknown[] = []): T[] {
30
+ try {
31
+ return this.db.prepare(sql).all(...(params as never[])) as T[]
32
+ } catch {
33
+ // If the DB file was swapped underneath us (app update), reopen once.
34
+ this.db = this.open()
35
+ return this.db.prepare(sql).all(...(params as never[])) as T[]
36
+ }
37
+ }
38
+ }
package/src/git.ts ADDED
@@ -0,0 +1,116 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { promisify } from 'node:util'
3
+
4
+ const exec = promisify(execFile)
5
+
6
+ export interface DiffFile {
7
+ path: string
8
+ added: number
9
+ removed: number
10
+ }
11
+
12
+ export interface WorkspaceDiff {
13
+ base: string
14
+ mergeBase: string | null
15
+ files: DiffFile[]
16
+ patch: string
17
+ truncated: boolean
18
+ }
19
+
20
+ const MAX_PATCH_BYTES = 400_000
21
+
22
+ async function git(cwd: string, args: string[]): Promise<string> {
23
+ const { stdout } = await exec('git', ['-C', cwd, ...args], {
24
+ encoding: 'utf8',
25
+ maxBuffer: 8 * 1024 * 1024,
26
+ timeout: 15000
27
+ })
28
+ return stdout
29
+ }
30
+
31
+ /**
32
+ * Patch for untracked files. `git diff` ignores them, but a reviewer wants to
33
+ * see new files — so we synthesize a "new file" diff via `--no-index` against
34
+ * /dev/null. This never touches the index (no `add -N`), so the live worktree
35
+ * the agent is using is left untouched.
36
+ */
37
+ async function untrackedDiff(cwd: string): Promise<{ files: DiffFile[]; patch: string }> {
38
+ let listing = ''
39
+ try {
40
+ listing = await git(cwd, ['ls-files', '--others', '--exclude-standard'])
41
+ } catch {
42
+ return { files: [], patch: '' }
43
+ }
44
+ const paths = listing.split('\n').filter(Boolean)
45
+ const files: DiffFile[] = []
46
+ const patches: string[] = []
47
+ for (const p of paths) {
48
+ try {
49
+ // --no-index exits 1 when files differ, so read stdout from the error.
50
+ await exec('git', ['-C', cwd, 'diff', '--no-index', '--no-color', '--', '/dev/null', p], {
51
+ encoding: 'utf8',
52
+ maxBuffer: 4 * 1024 * 1024,
53
+ timeout: 10000
54
+ })
55
+ } catch (err) {
56
+ const out = (err as { stdout?: string }).stdout ?? ''
57
+ if (!out) continue
58
+ const added = out.split('\n').filter(l => l.startsWith('+') && !l.startsWith('+++')).length
59
+ files.push({ path: p, added, removed: 0 })
60
+ patches.push(out)
61
+ }
62
+ }
63
+ return { files, patch: patches.join('') }
64
+ }
65
+
66
+ /** Resolve the base ref, preferring the remote-tracking form if it exists. */
67
+ async function resolveBase(cwd: string, base: string): Promise<string> {
68
+ for (const ref of [`origin/${base}`, base]) {
69
+ try {
70
+ await git(cwd, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
71
+ return ref
72
+ } catch {
73
+ // try next
74
+ }
75
+ }
76
+ return base
77
+ }
78
+
79
+ /**
80
+ * Everything the workspace changed relative to its target branch — committed
81
+ * plus uncommitted — which is what a reviewer wants to see. Computed straight
82
+ * from the worktree, so it's independent of Conductor entirely.
83
+ */
84
+ export async function workspaceDiff(worktree: string, base: string): Promise<WorkspaceDiff> {
85
+ const ref = await resolveBase(worktree, base)
86
+ let mergeBase: string | null = null
87
+ try {
88
+ mergeBase = (await git(worktree, ['merge-base', ref, 'HEAD'])).trim()
89
+ } catch {
90
+ mergeBase = null
91
+ }
92
+ const against = mergeBase ?? ref
93
+
94
+ const numstat = await git(worktree, ['diff', '--numstat', against]).catch(() => '')
95
+ const files: DiffFile[] = numstat
96
+ .split('\n')
97
+ .filter(Boolean)
98
+ .map(line => {
99
+ const [added, removed, ...rest] = line.split('\t')
100
+ return {
101
+ path: rest.join('\t'),
102
+ added: added === '-' ? 0 : Number(added),
103
+ removed: removed === '-' ? 0 : Number(removed)
104
+ }
105
+ })
106
+
107
+ const trackedPatch = await git(worktree, ['diff', against]).catch(() => '')
108
+ const untracked = await untrackedDiff(worktree)
109
+ files.push(...untracked.files)
110
+
111
+ let patch = trackedPatch + untracked.patch
112
+ const truncated = patch.length > MAX_PATCH_BYTES
113
+ if (truncated) patch = `${patch.slice(0, MAX_PATCH_BYTES)}\n\n… diff truncated (${patch.length} bytes) …`
114
+
115
+ return { base: ref, mergeBase, files, patch, truncated }
116
+ }
package/src/reads.ts ADDED
@@ -0,0 +1,164 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import type { ConductorDb } from './db.ts'
5
+ import { parseMessage, type TranscriptEntry } from './transcript.ts'
6
+
7
+ export interface WorkspaceRow {
8
+ id: string
9
+ directory_name: string | null
10
+ workspace_name: string | null
11
+ branch: string | null
12
+ derived_status: string | null
13
+ manual_status: string | null
14
+ updated_at: string
15
+ unread: number | null
16
+ pinned_at: string | null
17
+ active_session_id: string | null
18
+ intended_target_branch: string | null
19
+ repo_name: string | null
20
+ repo_root: string | null
21
+ default_branch: string | null
22
+ session_status: string | null
23
+ session_title: string | null
24
+ model: string | null
25
+ context_used_percent: number | null
26
+ }
27
+
28
+ export interface SessionRow {
29
+ id: string
30
+ status: string | null
31
+ title: string | null
32
+ model: string | null
33
+ permission_mode: string | null
34
+ context_used_percent: number | null
35
+ unread_count: number | null
36
+ created_at: string
37
+ updated_at: string
38
+ last_user_message_at: string | null
39
+ }
40
+
41
+ export interface Workspace extends WorkspaceRow {
42
+ /** Absolute path to the git worktree on disk, or null if it can't be resolved. */
43
+ worktree: string | null
44
+ baseBranch: string
45
+ }
46
+
47
+ const worktreeCache = new Map<string, string | null>()
48
+
49
+ /**
50
+ * Resolve a workspace's worktree path. Conductor lays worktrees out as
51
+ * `<workspacesRoot>/<repoName>/<directoryName>`, but we verify against
52
+ * `git worktree list` (matched by branch) so a layout change can't silently
53
+ * point us at the wrong tree.
54
+ */
55
+ function resolveWorktree(
56
+ workspacesRoot: string,
57
+ repoName: string | null,
58
+ directoryName: string | null,
59
+ branch: string | null,
60
+ repoRoot: string | null
61
+ ): string | null {
62
+ if (repoName && directoryName) {
63
+ const guess = path.join(workspacesRoot, repoName, directoryName)
64
+ if (fs.existsSync(path.join(guess, '.git'))) return guess
65
+ }
66
+ if (!(repoRoot && branch)) return null
67
+ const cacheKey = repoRoot
68
+ let listing = worktreeCache.get(cacheKey)
69
+ if (listing === undefined) {
70
+ try {
71
+ listing = execFileSync('git', ['-C', repoRoot, 'worktree', 'list', '--porcelain'], {
72
+ encoding: 'utf8',
73
+ timeout: 5000
74
+ })
75
+ } catch {
76
+ listing = null
77
+ }
78
+ worktreeCache.set(cacheKey, listing)
79
+ }
80
+ if (!listing) return null
81
+ // Porcelain: blocks of "worktree <path>" / "branch refs/heads/<name>"
82
+ const blocks = listing.split('\n\n')
83
+ for (const block of blocks) {
84
+ if (block.includes(`refs/heads/${branch}`)) {
85
+ const m = block.match(/^worktree (.+)$/m)
86
+ if (m) return m[1]
87
+ }
88
+ }
89
+ return null
90
+ }
91
+
92
+ export class Reads {
93
+ private readonly db: ConductorDb
94
+ private readonly workspacesRoot: string
95
+
96
+ constructor(db: ConductorDb, workspacesRoot: string) {
97
+ this.db = db
98
+ this.workspacesRoot = workspacesRoot
99
+ }
100
+
101
+ listWorkspaces(): Workspace[] {
102
+ const rows = this.db.query<WorkspaceRow>(
103
+ `SELECT w.id, w.directory_name, w.workspace_name, w.branch, w.derived_status, w.manual_status,
104
+ w.updated_at, w.unread, w.pinned_at, w.active_session_id, w.intended_target_branch,
105
+ r.name AS repo_name, r.root_path AS repo_root, r.default_branch AS default_branch,
106
+ s.status AS session_status, s.title AS session_title, s.model AS model,
107
+ s.context_used_percent AS context_used_percent
108
+ FROM workspaces w
109
+ LEFT JOIN repos r ON r.id = w.repository_id
110
+ LEFT JOIN sessions s ON s.id = w.active_session_id
111
+ WHERE w.state = 'ready'
112
+ ORDER BY (w.pinned_at IS NULL), w.updated_at DESC`
113
+ )
114
+ return rows.map(r => ({
115
+ ...r,
116
+ worktree: resolveWorktree(this.workspacesRoot, r.repo_name, r.directory_name, r.branch, r.repo_root),
117
+ baseBranch: r.intended_target_branch || r.default_branch || 'main'
118
+ }))
119
+ }
120
+
121
+ getWorkspace(id: string): Workspace | null {
122
+ return this.listWorkspaces().find(w => w.id === id) ?? null
123
+ }
124
+
125
+ listSessions(workspaceId: string): SessionRow[] {
126
+ // created_at ASC keeps tab order stable (matches the desktop app) instead of jumping on activity.
127
+ return this.db.query<SessionRow>(
128
+ `SELECT id, status, title, model, permission_mode, context_used_percent, unread_count,
129
+ created_at, updated_at, last_user_message_at
130
+ FROM sessions
131
+ WHERE workspace_id = ? AND COALESCE(is_hidden, 0) = 0
132
+ ORDER BY created_at ASC`,
133
+ [workspaceId]
134
+ )
135
+ }
136
+
137
+ /** Incremental transcript fetch. `afterRowid` is the cursor from a prior call. */
138
+ getMessages(sessionId: string, afterRowid = 0): { entries: TranscriptEntry[]; cursor: number } {
139
+ const rows = this.db.query<{
140
+ rowid: number
141
+ id: string
142
+ role: string | null
143
+ content: string | null
144
+ full_message: string | null
145
+ created_at: string
146
+ sent_at: string | null
147
+ queue_order: number | null
148
+ }>(
149
+ `SELECT rowid, id, role, content, full_message, created_at, sent_at, queue_order
150
+ FROM session_messages
151
+ WHERE session_id = ? AND rowid > ?
152
+ ORDER BY rowid ASC`,
153
+ [sessionId, afterRowid]
154
+ )
155
+ const entries: TranscriptEntry[] = []
156
+ let cursor = afterRowid
157
+ for (const row of rows) {
158
+ cursor = row.rowid
159
+ const entry = parseMessage(row)
160
+ if (entry) entries.push(entry)
161
+ }
162
+ return { entries, cursor }
163
+ }
164
+ }
package/src/server.ts ADDED
@@ -0,0 +1,149 @@
1
+ import fs from 'node:fs'
2
+ import http from 'node:http'
3
+ import path from 'node:path'
4
+ import { loadConfig } from './config.ts'
5
+ import { ConductorDb } from './db.ts'
6
+ import { workspaceDiff } from './git.ts'
7
+ import { Reads } from './reads.ts'
8
+ import { describeActuator, pickActuator } from './writes.ts'
9
+
10
+ const cfg = loadConfig()
11
+ const db = new ConductorDb(cfg.dbPath)
12
+ const reads = new Reads(db, cfg.workspacesRoot)
13
+ const actuator = pickActuator(cfg.writeStrategy)
14
+
15
+ const MIME: Record<string, string> = {
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
+
25
+ function json(res: http.ServerResponse, status: number, body: unknown): void {
26
+ const payload = JSON.stringify(body)
27
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
28
+ res.end(payload)
29
+ }
30
+
31
+ function authed(req: http.IncomingMessage): boolean {
32
+ const auth = req.headers.authorization
33
+ if (auth === `Bearer ${cfg.token}`) return true
34
+ const url = new URL(req.url ?? '/', 'http://x')
35
+ return url.searchParams.get('token') === cfg.token
36
+ }
37
+
38
+ async function readBody(req: http.IncomingMessage): Promise<string> {
39
+ const chunks: Buffer[] = []
40
+ for await (const c of req) chunks.push(c as Buffer)
41
+ return Buffer.concat(chunks).toString('utf8')
42
+ }
43
+
44
+ /** Hashed Vite assets are immutable and cache-forever; the shell/SW must never go stale. */
45
+ function cacheControl(rel: string): string {
46
+ if (rel.startsWith('assets/')) return 'public, max-age=31536000, immutable'
47
+ return 'no-cache'
48
+ }
49
+
50
+ function serveStatic(_req: http.IncomingMessage, res: http.ServerResponse, pathname: string): void {
51
+ const rel = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, '')
52
+ const filePath = path.join(cfg.publicDir, rel)
53
+ // Contain to publicDir.
54
+ if (!filePath.startsWith(cfg.publicDir)) {
55
+ res.writeHead(403).end()
56
+ return
57
+ }
58
+ fs.readFile(filePath, (err, data) => {
59
+ if (err) {
60
+ // SPA fallback to shell.
61
+ fs.readFile(path.join(cfg.publicDir, 'index.html'), (e2, shell) => {
62
+ if (e2) return void res.writeHead(404).end('not found')
63
+ res.writeHead(200, { 'content-type': MIME['.html'], 'cache-control': 'no-cache' })
64
+ res.end(shell)
65
+ })
66
+ return
67
+ }
68
+ const ext = path.extname(filePath)
69
+ res.writeHead(200, { 'content-type': MIME[ext] ?? 'application/octet-stream', 'cache-control': cacheControl(rel) })
70
+ res.end(data)
71
+ })
72
+ }
73
+
74
+ const server = http.createServer(async (req, res) => {
75
+ const url = new URL(req.url ?? '/', 'http://x')
76
+ const { pathname } = url
77
+
78
+ if (!pathname.startsWith('/api/')) return serveStatic(req, res, pathname)
79
+
80
+ // Everything under /api requires the shared secret.
81
+ if (!authed(req)) return json(res, 401, { error: 'unauthorized' })
82
+
83
+ try {
84
+ // GET /api/state — workspace list with active-session status
85
+ if (req.method === 'GET' && pathname === '/api/state') {
86
+ return json(res, 200, {
87
+ workspaces: reads.listWorkspaces(),
88
+ actuator: await describeActuator(actuator)
89
+ })
90
+ }
91
+
92
+ // GET /api/workspaces/:id/sessions
93
+ let m = pathname.match(/^\/api\/workspaces\/([^/]+)\/sessions$/)
94
+ if (req.method === 'GET' && m) {
95
+ return json(res, 200, { sessions: reads.listSessions(decodeURIComponent(m[1])) })
96
+ }
97
+
98
+ // GET /api/workspaces/:id/diff
99
+ m = pathname.match(/^\/api\/workspaces\/([^/]+)\/diff$/)
100
+ if (req.method === 'GET' && m) {
101
+ const ws = reads.getWorkspace(decodeURIComponent(m[1]))
102
+ if (!ws) return json(res, 404, { error: 'workspace not found' })
103
+ if (!ws.worktree) return json(res, 409, { error: 'worktree path unresolved' })
104
+ const diff = await workspaceDiff(ws.worktree, ws.baseBranch)
105
+ return json(res, 200, diff)
106
+ }
107
+
108
+ // GET /api/sessions/:id/messages?after=<rowid>
109
+ m = pathname.match(/^\/api\/sessions\/([^/]+)\/messages$/)
110
+ if (req.method === 'GET' && m) {
111
+ const after = Number(url.searchParams.get('after') ?? 0)
112
+ return json(res, 200, reads.getMessages(decodeURIComponent(m[1]), Number.isFinite(after) ? after : 0))
113
+ }
114
+
115
+ // POST /api/sessions/:id/prompt { text }
116
+ m = pathname.match(/^\/api\/sessions\/([^/]+)\/prompt$/)
117
+ if (req.method === 'POST' && m) {
118
+ const sessionId = decodeURIComponent(m[1])
119
+ const body = JSON.parse((await readBody(req)) || '{}') as { text?: string; workspaceId?: string }
120
+ const text = (body.text ?? '').trim()
121
+ if (!text) return json(res, 400, { error: 'empty prompt' })
122
+ const ws = body.workspaceId
123
+ ? reads.getWorkspace(body.workspaceId)
124
+ : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null)
125
+ if (!ws) return json(res, 404, { error: 'workspace for session not found' })
126
+ const result = await actuator.send({ workspace: ws, sessionId }, text)
127
+ return json(res, result.ok ? 200 : 502, result)
128
+ }
129
+
130
+ return json(res, 404, { error: 'no route', pathname })
131
+ } catch (err) {
132
+ return json(res, 500, { error: err instanceof Error ? err.message : String(err) })
133
+ }
134
+ })
135
+
136
+ server.listen(cfg.port, cfg.host, () => {
137
+ console.info(
138
+ [
139
+ 'conductor-remote relay up',
140
+ ` db: ${cfg.dbPath}`,
141
+ ` worktrees: ${cfg.workspacesRoot}`,
142
+ ` actuator: ${actuator.name}`,
143
+ ` bound: ${cfg.host}:${cfg.port}`,
144
+ '',
145
+ ` Local: http://${cfg.host}:${cfg.port}/#token=${cfg.token}`,
146
+ ' Phone: fronted over the tailnet by `tailscale serve` — run `yarn service status` for the HTTPS URL'
147
+ ].join('\n')
148
+ )
149
+ })