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/LICENSE +21 -0
- package/README.md +206 -0
- package/bin/cli.js +64 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/index-DiiwvzTm.css +1 -0
- package/dist/assets/index-DtY88CkY.js +40 -0
- package/dist/assets/workbox-window.prod.es5-BBnX5xw4.js +2 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/icon-maskable-512.png +0 -0
- package/dist/icon.svg +28 -0
- package/dist/index.html +20 -0
- package/dist/manifest.webmanifest +1 -0
- package/dist/sw.js +1 -0
- package/dist/workbox-9c191d2f.js +1 -0
- package/package.json +86 -0
- package/scripts/dev.ts +75 -0
- package/scripts/devtools-recon.js +42 -0
- package/scripts/gen-icons.ts +22 -0
- package/scripts/service.ts +308 -0
- package/src/config.ts +81 -0
- package/src/db.ts +38 -0
- package/src/git.ts +116 -0
- package/src/reads.ts +164 -0
- package/src/server.ts +149 -0
- package/src/sidecar.ts +194 -0
- package/src/transcript.ts +111 -0
- package/src/writes.ts +133 -0
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
|
+
})
|