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/src/config.ts DELETED
@@ -1,81 +0,0 @@
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 DELETED
@@ -1,38 +0,0 @@
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 DELETED
@@ -1,116 +0,0 @@
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/icons.ts DELETED
@@ -1,66 +0,0 @@
1
- import fs from 'node:fs'
2
- import path from 'node:path'
3
-
4
- /**
5
- * Repo-icon resolution, mirroring Conductor's own logic so the phone sidebar shows
6
- * the same avatar as the desktop app. Conductor looks for a known icon filename in
7
- * the repository root and uses the first match — see
8
- * https://www.conductor.build/docs/faq#where-does-conductor-get-the-repo-icon
9
- *
10
- * This reads from the repo's root checkout (the shared, canonical tree), not a
11
- * per-workspace worktree, so every workspace of a repo resolves to one icon.
12
- */
13
- const ICON_CANDIDATES = [
14
- 'public/apple-touch-icon.png',
15
- 'apple-touch-icon.png',
16
- 'public/favicon.svg',
17
- 'favicon.svg',
18
- 'public/favicon.png',
19
- 'public/icon.png',
20
- 'public/logo.png',
21
- 'favicon.png',
22
- 'app/icon.png',
23
- 'src/app/icon.png',
24
- 'public/favicon.ico',
25
- 'favicon.ico',
26
- 'app/favicon.ico',
27
- 'static/favicon.ico',
28
- 'src-tauri/icons/icon.png',
29
- 'assets/icon.png',
30
- 'src/assets/icon.png'
31
- ]
32
-
33
- const CONTENT_TYPES: Record<string, string> = {
34
- '.png': 'image/png',
35
- '.svg': 'image/svg+xml',
36
- '.ico': 'image/x-icon'
37
- }
38
-
39
- export interface ResolvedIcon {
40
- /** Absolute path to the icon file on disk. */
41
- path: string
42
- contentType: string
43
- }
44
-
45
- // Icons rarely change; a short TTL keeps the 2.5s state poll from stat-storming the
46
- // disk while still picking up a freshly-added icon within a tick or two.
47
- const TTL_MS = 30_000
48
- const cache = new Map<string, { at: number; icon: ResolvedIcon | null }>()
49
-
50
- /** First matching icon under `repoRoot`, or null if the repo has none. Cached per root. */
51
- export function resolveRepoIcon(repoRoot: string): ResolvedIcon | null {
52
- const now = Date.now()
53
- const hit = cache.get(repoRoot)
54
- if (hit && now - hit.at < TTL_MS) return hit.icon
55
-
56
- let icon: ResolvedIcon | null = null
57
- for (const rel of ICON_CANDIDATES) {
58
- const abs = path.join(repoRoot, rel)
59
- if (fs.existsSync(abs)) {
60
- icon = { path: abs, contentType: CONTENT_TYPES[path.extname(abs).toLowerCase()] ?? 'application/octet-stream' }
61
- break
62
- }
63
- }
64
- cache.set(repoRoot, { at: now, icon })
65
- return icon
66
- }
package/src/reads.ts DELETED
@@ -1,178 +0,0 @@
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 { type ResolvedIcon, resolveRepoIcon } from './icons.ts'
6
- import { parseMessage, type TranscriptEntry } from './transcript.ts'
7
-
8
- export interface WorkspaceRow {
9
- id: string
10
- directory_name: string | null
11
- workspace_name: string | null
12
- branch: string | null
13
- derived_status: string | null
14
- manual_status: string | null
15
- created_at: string
16
- updated_at: string
17
- unread: number | null
18
- pinned_at: string | null
19
- active_session_id: string | null
20
- intended_target_branch: string | null
21
- repo_name: string | null
22
- repo_root: string | null
23
- default_branch: string | null
24
- session_status: string | null
25
- session_title: string | null
26
- model: string | null
27
- context_used_percent: number | null
28
- }
29
-
30
- export interface SessionRow {
31
- id: string
32
- status: string | null
33
- title: string | null
34
- model: string | null
35
- permission_mode: string | null
36
- context_used_percent: number | null
37
- unread_count: number | null
38
- created_at: string
39
- updated_at: string
40
- last_user_message_at: string | null
41
- }
42
-
43
- export interface Workspace extends WorkspaceRow {
44
- /** Absolute path to the git worktree on disk, or null if it can't be resolved. */
45
- worktree: string | null
46
- baseBranch: string
47
- /** Whether the repo has a resolvable icon (fetch it from `/api/repos/:name/icon`). */
48
- hasRepoIcon: boolean
49
- }
50
-
51
- const worktreeCache = new Map<string, string | null>()
52
-
53
- /**
54
- * Resolve a workspace's worktree path. Conductor lays worktrees out as
55
- * `<workspacesRoot>/<repoName>/<directoryName>`, but we verify against
56
- * `git worktree list` (matched by branch) so a layout change can't silently
57
- * point us at the wrong tree.
58
- */
59
- function resolveWorktree(
60
- workspacesRoot: string,
61
- repoName: string | null,
62
- directoryName: string | null,
63
- branch: string | null,
64
- repoRoot: string | null
65
- ): string | null {
66
- if (repoName && directoryName) {
67
- const guess = path.join(workspacesRoot, repoName, directoryName)
68
- if (fs.existsSync(path.join(guess, '.git'))) return guess
69
- }
70
- if (!(repoRoot && branch)) return null
71
- const cacheKey = repoRoot
72
- let listing = worktreeCache.get(cacheKey)
73
- if (listing === undefined) {
74
- try {
75
- listing = execFileSync('git', ['-C', repoRoot, 'worktree', 'list', '--porcelain'], {
76
- encoding: 'utf8',
77
- timeout: 5000
78
- })
79
- } catch {
80
- listing = null
81
- }
82
- worktreeCache.set(cacheKey, listing)
83
- }
84
- if (!listing) return null
85
- // Porcelain: blocks of "worktree <path>" / "branch refs/heads/<name>"
86
- const blocks = listing.split('\n\n')
87
- for (const block of blocks) {
88
- if (block.includes(`refs/heads/${branch}`)) {
89
- const m = block.match(/^worktree (.+)$/m)
90
- if (m) return m[1]
91
- }
92
- }
93
- return null
94
- }
95
-
96
- export class Reads {
97
- private readonly db: ConductorDb
98
- private readonly workspacesRoot: string
99
-
100
- constructor(db: ConductorDb, workspacesRoot: string) {
101
- this.db = db
102
- this.workspacesRoot = workspacesRoot
103
- }
104
-
105
- listWorkspaces(): Workspace[] {
106
- const rows = this.db.query<WorkspaceRow>(
107
- `SELECT w.id, w.directory_name, w.workspace_name, w.branch, w.derived_status, w.manual_status,
108
- w.created_at, w.updated_at, w.unread, w.pinned_at, w.active_session_id, w.intended_target_branch,
109
- r.name AS repo_name, r.root_path AS repo_root, r.default_branch AS default_branch,
110
- s.status AS session_status, s.title AS session_title, s.model AS model,
111
- s.context_used_percent AS context_used_percent
112
- FROM workspaces w
113
- LEFT JOIN repos r ON r.id = w.repository_id
114
- LEFT JOIN sessions s ON s.id = w.active_session_id
115
- WHERE w.state = 'ready'
116
- ORDER BY (w.pinned_at IS NULL), w.updated_at DESC`
117
- )
118
- return rows.map(r => ({
119
- ...r,
120
- worktree: resolveWorktree(this.workspacesRoot, r.repo_name, r.directory_name, r.branch, r.repo_root),
121
- baseBranch: r.intended_target_branch || r.default_branch || 'main',
122
- hasRepoIcon: !!(r.repo_root && resolveRepoIcon(r.repo_root))
123
- }))
124
- }
125
-
126
- getWorkspace(id: string): Workspace | null {
127
- return this.listWorkspaces().find(w => w.id === id) ?? null
128
- }
129
-
130
- /** Resolve a repo's icon by its name (the sidebar avatar) — null if the repo or icon is unknown. */
131
- resolveRepoIcon(repoName: string): ResolvedIcon | null {
132
- const rows = this.db.query<{ root_path: string | null }>('SELECT root_path FROM repos WHERE name = ? LIMIT 1', [
133
- repoName
134
- ])
135
- const root = rows[0]?.root_path
136
- return root ? resolveRepoIcon(root) : null
137
- }
138
-
139
- listSessions(workspaceId: string): SessionRow[] {
140
- // created_at ASC keeps tab order stable (matches the desktop app) instead of jumping on activity.
141
- return this.db.query<SessionRow>(
142
- `SELECT id, status, title, model, permission_mode, context_used_percent, unread_count,
143
- created_at, updated_at, last_user_message_at
144
- FROM sessions
145
- WHERE workspace_id = ? AND COALESCE(is_hidden, 0) = 0
146
- ORDER BY created_at ASC`,
147
- [workspaceId]
148
- )
149
- }
150
-
151
- /** Incremental transcript fetch. `afterRowid` is the cursor from a prior call. */
152
- getMessages(sessionId: string, afterRowid = 0): { entries: TranscriptEntry[]; cursor: number } {
153
- const rows = this.db.query<{
154
- rowid: number
155
- id: string
156
- role: string | null
157
- content: string | null
158
- full_message: string | null
159
- created_at: string
160
- sent_at: string | null
161
- queue_order: number | null
162
- }>(
163
- `SELECT rowid, id, role, content, full_message, created_at, sent_at, queue_order
164
- FROM session_messages
165
- WHERE session_id = ? AND rowid > ?
166
- ORDER BY rowid ASC`,
167
- [sessionId, afterRowid]
168
- )
169
- const entries: TranscriptEntry[] = []
170
- let cursor = afterRowid
171
- for (const row of rows) {
172
- cursor = row.rowid
173
- const entry = parseMessage(row)
174
- if (entry) entries.push(entry)
175
- }
176
- return { entries, cursor }
177
- }
178
- }
package/src/server.ts DELETED
@@ -1,194 +0,0 @@
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.ts'
6
- import { loadConfig } from './config.ts'
7
- import { ConductorDb } from './db.ts'
8
- import { workspaceDiff } from './git.ts'
9
- import { Reads } from './reads.ts'
10
- import { describeActuator, newChat, pickActuator } from './writes.ts'
11
-
12
- const cfg = loadConfig()
13
- const db = new ConductorDb(cfg.dbPath)
14
- const reads = new Reads(db, cfg.workspacesRoot)
15
- const actuator = pickActuator(cfg.writeStrategy)
16
-
17
- const MIME: Record<string, string> = {
18
- '.html': 'text/html; charset=utf-8',
19
- '.js': 'text/javascript; charset=utf-8',
20
- '.css': 'text/css; charset=utf-8',
21
- '.json': 'application/json; charset=utf-8',
22
- '.webmanifest': 'application/manifest+json; charset=utf-8',
23
- '.svg': 'image/svg+xml',
24
- '.png': 'image/png'
25
- }
26
-
27
- function json(res: http.ServerResponse, status: number, body: unknown): void {
28
- const payload = JSON.stringify(body)
29
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
30
- res.end(payload)
31
- }
32
-
33
- /** Constant-time string compare — the token is the sole internet-facing gate when exposed via Funnel. */
34
- function tokenEq(candidate: string | null): boolean {
35
- if (candidate == null) return false
36
- const a = Buffer.from(candidate)
37
- const b = Buffer.from(cfg.token)
38
- return a.length === b.length && crypto.timingSafeEqual(a, b)
39
- }
40
-
41
- function authed(req: http.IncomingMessage): boolean {
42
- const auth = req.headers.authorization
43
- if (auth?.startsWith('Bearer ')) return tokenEq(auth.slice('Bearer '.length))
44
- const url = new URL(req.url ?? '/', 'http://x')
45
- return tokenEq(url.searchParams.get('token'))
46
- }
47
-
48
- async function readBody(req: http.IncomingMessage): Promise<string> {
49
- const chunks: Buffer[] = []
50
- for await (const c of req) chunks.push(c as Buffer)
51
- return Buffer.concat(chunks).toString('utf8')
52
- }
53
-
54
- /** Hashed Vite assets are immutable and cache-forever; the shell/SW must never go stale. */
55
- function cacheControl(rel: string): string {
56
- if (rel.startsWith('assets/')) return 'public, max-age=31536000, immutable'
57
- return 'no-cache'
58
- }
59
-
60
- function serveStatic(_req: http.IncomingMessage, res: http.ServerResponse, pathname: string): void {
61
- const rel = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, '')
62
- const filePath = path.join(cfg.publicDir, rel)
63
- // Contain to publicDir.
64
- if (!filePath.startsWith(cfg.publicDir)) {
65
- res.writeHead(403).end()
66
- return
67
- }
68
- fs.readFile(filePath, (err, data) => {
69
- if (err) {
70
- // SPA fallback to shell.
71
- fs.readFile(path.join(cfg.publicDir, 'index.html'), (e2, shell) => {
72
- if (e2) return void res.writeHead(404).end('not found')
73
- res.writeHead(200, { 'content-type': MIME['.html'], 'cache-control': 'no-cache' })
74
- res.end(shell)
75
- })
76
- return
77
- }
78
- const ext = path.extname(filePath)
79
- res.writeHead(200, { 'content-type': MIME[ext] ?? 'application/octet-stream', 'cache-control': cacheControl(rel) })
80
- res.end(data)
81
- })
82
- }
83
-
84
- const server = http.createServer(async (req, res) => {
85
- const url = new URL(req.url ?? '/', 'http://x')
86
- const { pathname } = url
87
-
88
- if (!pathname.startsWith('/api/')) return serveStatic(req, res, pathname)
89
-
90
- // Everything under /api requires the shared secret.
91
- if (!authed(req)) return json(res, 401, { error: 'unauthorized' })
92
-
93
- try {
94
- // GET /api/state — workspace list with active-session status
95
- if (req.method === 'GET' && pathname === '/api/state') {
96
- const update = updateStatus()
97
- return json(res, 200, {
98
- workspaces: reads.listWorkspaces(),
99
- actuator: await describeActuator(actuator),
100
- version: update.current,
101
- update
102
- })
103
- }
104
-
105
- // GET /api/repos/:name/icon — the repo's resolved sidebar icon (see src/icons.ts)
106
- let m = pathname.match(/^\/api\/repos\/([^/]+)\/icon$/)
107
- if (req.method === 'GET' && m) {
108
- const icon = reads.resolveRepoIcon(decodeURIComponent(m[1]))
109
- if (!icon) return json(res, 404, { error: 'no icon' })
110
- return void fs.readFile(icon.path, (err, data) => {
111
- if (err) return void json(res, 404, { error: 'no icon' })
112
- // Cache briefly on the phone; the resolver itself refreshes within ~30s of an icon change.
113
- res.writeHead(200, { 'content-type': icon.contentType, 'cache-control': 'public, max-age=300' })
114
- res.end(data)
115
- })
116
- }
117
-
118
- // GET /api/workspaces/:id/sessions
119
- m = pathname.match(/^\/api\/workspaces\/([^/]+)\/sessions$/)
120
- if (req.method === 'GET' && m) {
121
- return json(res, 200, { sessions: reads.listSessions(decodeURIComponent(m[1])) })
122
- }
123
-
124
- // POST /api/workspaces/:id/sessions — open a new chat (Cmd+T) in the workspace
125
- if (req.method === 'POST' && m) {
126
- const workspaceId = decodeURIComponent(m[1])
127
- const ws = reads.getWorkspace(workspaceId)
128
- if (!ws) return json(res, 404, { error: 'workspace not found' })
129
- const before = new Set(reads.listSessions(workspaceId).map(s => s.id))
130
- const result = await newChat(ws)
131
- if (!result.ok) return json(res, 502, result)
132
- // The new session lands in the DB a beat after Cmd+T — poll for the fresh id.
133
- let sessionId: string | null = null
134
- for (let i = 0; i < 12 && !sessionId; i++) {
135
- await new Promise(r => setTimeout(r, 500))
136
- sessionId = reads.listSessions(workspaceId).find(s => !before.has(s.id))?.id ?? null
137
- }
138
- return json(res, 200, { ok: true, sessionId })
139
- }
140
-
141
- // GET /api/workspaces/:id/diff
142
- m = pathname.match(/^\/api\/workspaces\/([^/]+)\/diff$/)
143
- if (req.method === 'GET' && m) {
144
- const ws = reads.getWorkspace(decodeURIComponent(m[1]))
145
- if (!ws) return json(res, 404, { error: 'workspace not found' })
146
- if (!ws.worktree) return json(res, 409, { error: 'worktree path unresolved' })
147
- const diff = await workspaceDiff(ws.worktree, ws.baseBranch)
148
- return json(res, 200, diff)
149
- }
150
-
151
- // GET /api/sessions/:id/messages?after=<rowid>
152
- m = pathname.match(/^\/api\/sessions\/([^/]+)\/messages$/)
153
- if (req.method === 'GET' && m) {
154
- const after = Number(url.searchParams.get('after') ?? 0)
155
- return json(res, 200, reads.getMessages(decodeURIComponent(m[1]), Number.isFinite(after) ? after : 0))
156
- }
157
-
158
- // POST /api/sessions/:id/prompt { text }
159
- m = pathname.match(/^\/api\/sessions\/([^/]+)\/prompt$/)
160
- if (req.method === 'POST' && m) {
161
- const sessionId = decodeURIComponent(m[1])
162
- const body = JSON.parse((await readBody(req)) || '{}') as { text?: string; workspaceId?: string }
163
- const text = (body.text ?? '').trim()
164
- if (!text) return json(res, 400, { error: 'empty prompt' })
165
- const ws = body.workspaceId
166
- ? reads.getWorkspace(body.workspaceId)
167
- : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null)
168
- if (!ws) return json(res, 404, { error: 'workspace for session not found' })
169
- const result = await actuator.send({ workspace: ws, sessionId }, text)
170
- return json(res, result.ok ? 200 : 502, result)
171
- }
172
-
173
- return json(res, 404, { error: 'no route', pathname })
174
- } catch (err) {
175
- return json(res, 500, { error: err instanceof Error ? err.message : String(err) })
176
- }
177
- })
178
-
179
- server.listen(cfg.port, cfg.host, () => {
180
- console.info(
181
- [
182
- 'conductor-remote relay up',
183
- ` db: ${cfg.dbPath}`,
184
- ` worktrees: ${cfg.workspacesRoot}`,
185
- ` actuator: ${actuator.name}`,
186
- ` bound: ${cfg.host}:${cfg.port}`,
187
- '',
188
- ` Local: http://${cfg.host}:${cfg.port}/#token=${cfg.token}`,
189
- ' Phone: fronted by `tailscale funnel`/`serve` — run `yarn service status` for the HTTPS URL'
190
- ].join('\n')
191
- )
192
- // Keep the managed global daemon current — no-ops for dev checkouts / unmanaged runs (see autoupdate.ts).
193
- startAutoUpdate()
194
- })