remo-code-supervisor 0.1.0

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/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # remo-code-supervisor
2
+
3
+ Local supervisor for Remo Code — manage and remote-launch `claude-code` sessions in your local repos from the Remo Code web UI.
4
+
5
+ ## Prerequisites
6
+
7
+ - Windows 10/11 (other OSes can run the foreground mode but not the service)
8
+ - [Bun](https://bun.sh) installed and on PATH
9
+ - `claude` CLI installed and on PATH
10
+ - `git` on PATH
11
+ - A Remo Code account with an API key (https://app.remo-code.com → Settings → API Keys)
12
+
13
+ ## Install
14
+
15
+ ```powershell
16
+ npx remo-code-supervisor install `
17
+ --api-key olx_xxx `
18
+ --roots "C:\Users\you\GitHub" `
19
+ --service-user ".\you" `
20
+ --service-password "<your windows password>"
21
+ ```
22
+
23
+ This installs a Windows Service named `RemoCodeSupervisor` (via NSSM) that runs as your user, auto-starts at boot, and stays connected to the hub.
24
+
25
+ If you don't supply `--service-user`/`--service-password`, the service is created but runs as LocalSystem — which **cannot read your SSH keys, `gh` auth, or `~/.config`**. Strongly prefer running as your own user.
26
+
27
+ ## Run in foreground (no service)
28
+
29
+ ```powershell
30
+ npx remo-code-supervisor run
31
+ ```
32
+
33
+ ## Other commands
34
+
35
+ ```powershell
36
+ npx remo-code-supervisor status # service status
37
+ npx remo-code-supervisor scan # list discovered repos
38
+ npx remo-code-supervisor uninstall # remove the service
39
+ ```
40
+
41
+ ## Config file
42
+
43
+ `%APPDATA%\remo-code\supervisor.json`
44
+
45
+ ```json
46
+ {
47
+ "api_key": "olx_xxx",
48
+ "hub_url": "https://app.remo-code.com",
49
+ "roots": ["C:\\Users\\you\\GitHub"],
50
+ "max_concurrent": 1
51
+ }
52
+ ```
53
+
54
+ ## Logs
55
+
56
+ `%LOCALAPPDATA%\remo-code\logs\stdout.log` and `stderr.log` (rotating at 10MB).
57
+
58
+ ## License
59
+
60
+ MIT
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "remo-code-supervisor",
3
+ "version": "0.1.0",
4
+ "description": "Local supervisor for Remo Code — manage and remote-launch claude-code sessions in your local repos from the web UI",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "bin": {
8
+ "remo-code-supervisor": "src/index.ts"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "dev": "bun --watch src/index.ts run",
16
+ "start": "bun src/index.ts run"
17
+ },
18
+ "engines": {
19
+ "bun": ">=1.0.0"
20
+ },
21
+ "keywords": ["claude", "claude-code", "remote", "supervisor", "remo-code"],
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/finedesignz/remo-code.git",
26
+ "directory": "supervisor"
27
+ }
28
+ }
package/src/config.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
2
+ import { join, dirname } from 'path'
3
+ import { homedir, platform } from 'os'
4
+
5
+ export interface SupervisorConfig {
6
+ hubUrl: string
7
+ apiKey: string
8
+ roots: string[]
9
+ maxConcurrent: number
10
+ }
11
+
12
+ function defaultConfigDir(): string {
13
+ if (platform() === 'win32') {
14
+ const appData = process.env.APPDATA || join(homedir(), 'AppData', 'Roaming')
15
+ return join(appData, 'remo-code')
16
+ }
17
+ const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), '.config')
18
+ return join(xdg, 'remo-code')
19
+ }
20
+
21
+ export const CONFIG_DIR = defaultConfigDir()
22
+ export const CONFIG_PATH = join(CONFIG_DIR, 'supervisor.json')
23
+ const DEFAULT_HUB_URL = 'https://app.remo-code.com'
24
+
25
+ export function loadConfig(): SupervisorConfig {
26
+ if (!existsSync(CONFIG_PATH)) {
27
+ throw new Error(`Supervisor not configured. Run: npx remo-code-supervisor install --api-key <olx_...>`)
28
+ }
29
+ const raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'))
30
+ if (!raw.api_key) throw new Error('config missing api_key')
31
+ return {
32
+ hubUrl: raw.hub_url || DEFAULT_HUB_URL,
33
+ apiKey: raw.api_key,
34
+ roots: raw.roots || [],
35
+ maxConcurrent: raw.max_concurrent || 1,
36
+ }
37
+ }
38
+
39
+ export function saveConfig(cfg: Partial<SupervisorConfig> & { apiKey: string }) {
40
+ mkdirSync(CONFIG_DIR, { recursive: true })
41
+ const existing = existsSync(CONFIG_PATH) ? JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')) : {}
42
+ const merged = {
43
+ ...existing,
44
+ api_key: cfg.apiKey,
45
+ hub_url: cfg.hubUrl || existing.hub_url || DEFAULT_HUB_URL,
46
+ roots: cfg.roots || existing.roots || [],
47
+ max_concurrent: cfg.maxConcurrent || existing.max_concurrent || 1,
48
+ }
49
+ writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2), 'utf-8')
50
+ }
51
+
52
+ export function parseRoots(input: string | undefined): string[] {
53
+ if (!input) return []
54
+ return input.split(/[,;]/).map((s) => s.trim()).filter(Boolean)
55
+ }
package/src/git-ops.ts ADDED
@@ -0,0 +1,85 @@
1
+ import { existsSync, writeFileSync, readFileSync } from 'fs'
2
+ import { join } from 'path'
3
+
4
+ async function runGit(args: string[], cwd?: string, timeoutMs = 60_000): Promise<{ stdout: string; stderr: string; code: number }> {
5
+ const proc = Bun.spawn(['git', ...args], {
6
+ cwd,
7
+ stdout: 'pipe',
8
+ stderr: 'pipe',
9
+ })
10
+ let timer: ReturnType<typeof setTimeout> | null = null
11
+ const timeout = new Promise<never>((_, reject) => {
12
+ timer = setTimeout(() => {
13
+ try { proc.kill() } catch {}
14
+ reject(new Error(`git timed out: ${args.join(' ')}`))
15
+ }, timeoutMs)
16
+ })
17
+ try {
18
+ const code = await Promise.race([proc.exited, timeout])
19
+ const stdout = await new Response(proc.stdout).text()
20
+ const stderr = await new Response(proc.stderr).text()
21
+ if (code !== 0) throw new Error(stderr.trim() || `git exited ${code}`)
22
+ return { stdout, stderr, code: code as number }
23
+ } finally {
24
+ if (timer) clearTimeout(timer)
25
+ }
26
+ }
27
+
28
+ export async function isDirty(repoPath: string): Promise<boolean> {
29
+ try {
30
+ const { stdout } = await runGit(['status', '--porcelain'], repoPath, 10_000)
31
+ return stdout.trim().length > 0
32
+ } catch { return false }
33
+ }
34
+
35
+ export async function currentBranch(repoPath: string): Promise<string | null> {
36
+ try {
37
+ const { stdout } = await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath, 10_000)
38
+ return stdout.trim() || null
39
+ } catch { return null }
40
+ }
41
+
42
+ export interface GitOpResult {
43
+ ok: boolean
44
+ error?: string
45
+ data?: any
46
+ }
47
+
48
+ export async function cloneRepo(cloneUrl: string, targetPath: string): Promise<GitOpResult> {
49
+ try {
50
+ if (existsSync(targetPath)) return { ok: false, error: `target already exists: ${targetPath}` }
51
+ await runGit(['clone', '--no-recurse-submodules', cloneUrl, targetPath], undefined, 600_000)
52
+ const cfgPath = join(targetPath, '.git', 'config')
53
+ if (existsSync(cfgPath)) {
54
+ const raw = readFileSync(cfgPath, 'utf-8')
55
+ const stripped = raw.replace(/https:\/\/[^@\s]+@github\.com/g, 'https://github.com')
56
+ writeFileSync(cfgPath, stripped, 'utf-8')
57
+ }
58
+ return { ok: true, data: { path: targetPath } }
59
+ } catch (err: any) {
60
+ return { ok: false, error: err.message }
61
+ }
62
+ }
63
+
64
+ export async function pullRepo(repoPath: string, branch: string, tokenizedUrl: string): Promise<GitOpResult> {
65
+ try {
66
+ if (await isDirty(repoPath)) return { ok: false, error: 'worktree is dirty; refusing to pull' }
67
+ await runGit(['fetch', tokenizedUrl, branch], repoPath, 120_000)
68
+ await runGit(['checkout', branch], repoPath, 60_000)
69
+ await runGit(['merge', '--ff-only', 'FETCH_HEAD'], repoPath, 60_000)
70
+ return { ok: true }
71
+ } catch (err: any) {
72
+ return { ok: false, error: err.message }
73
+ }
74
+ }
75
+
76
+ export async function checkoutBranch(repoPath: string, branch: string, create: boolean): Promise<GitOpResult> {
77
+ try {
78
+ if (await isDirty(repoPath)) return { ok: false, error: 'worktree is dirty; refusing to switch branches' }
79
+ if (create) await runGit(['checkout', '-b', branch], repoPath, 30_000)
80
+ else await runGit(['checkout', branch], repoPath, 30_000)
81
+ return { ok: true }
82
+ } catch (err: any) {
83
+ return { ok: false, error: err.message }
84
+ }
85
+ }
@@ -0,0 +1,195 @@
1
+ import { hostname, platform, release } from 'os'
2
+ import { scanAll } from './repo-scanner'
3
+ import { cloneRepo, pullRepo, checkoutBranch } from './git-ops'
4
+ import { ProcessManager, type ProcState } from './process-manager'
5
+ import type { SupervisorConfig } from './config'
6
+
7
+ const VERSION = '0.1.0'
8
+
9
+ type OutboundMsg =
10
+ | { type: 'auth'; api_key: string; project_dir: string; hostname: string; role: 'supervisor' }
11
+ | { type: 'supervisor.hello'; version: string; os: string; hostname: string; roots: string[]; capabilities: string[] }
12
+ | { type: 'supervisor.state'; state: ProcState; run_id?: string | null; repo_path?: string | null; pid?: number | null; restart_count?: number; last_exit?: any }
13
+ | { type: 'supervisor.log'; level: string; message: string; run_id?: string; ts?: string }
14
+ | { type: 'repo.scan_result'; req_id: string; repos: any[] }
15
+ | { type: 'repo.op_result'; req_id: string; op: string; ok: boolean; error?: string; data?: any }
16
+ | { type: 'repo.clone_progress'; req_id: string; stage: string; percent?: number }
17
+ | { type: 'pong' }
18
+
19
+ export class SupervisorClient {
20
+ private ws: WebSocket | null = null
21
+ private cfg: SupervisorConfig
22
+ private authenticated = false
23
+ private reconnectAttempts = 0
24
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null
25
+ private pm: ProcessManager
26
+
27
+ constructor(cfg: SupervisorConfig) {
28
+ this.cfg = cfg
29
+ this.pm = new ProcessManager({
30
+ onStateChange: (state, info) => {
31
+ this.send({
32
+ type: 'supervisor.state',
33
+ state,
34
+ run_id: info.runId ?? null,
35
+ repo_path: info.repoPath ?? null,
36
+ pid: info.pid ?? null,
37
+ restart_count: info.restartCount ?? 0,
38
+ last_exit: info.lastExit,
39
+ })
40
+ },
41
+ onLog: (level, message, runId) => {
42
+ this.log(level, message, runId)
43
+ },
44
+ })
45
+ }
46
+
47
+ connect() {
48
+ const wsUrl = this.cfg.hubUrl.replace('https://', 'wss://').replace('http://', 'ws://') + '/ws/agent'
49
+ this.log('info', `connecting to ${wsUrl}`)
50
+ try {
51
+ this.ws = new WebSocket(wsUrl)
52
+ } catch (err: any) {
53
+ this.log('error', `WebSocket construct failed: ${err.message}`)
54
+ this.scheduleReconnect()
55
+ return
56
+ }
57
+ this.ws.onopen = () => {
58
+ this.reconnectAttempts = 0
59
+ this.send({
60
+ type: 'auth',
61
+ api_key: this.cfg.apiKey,
62
+ project_dir: '__supervisor__',
63
+ hostname: hostname(),
64
+ role: 'supervisor',
65
+ })
66
+ }
67
+ this.ws.onmessage = (event) => {
68
+ let msg: any
69
+ try { msg = JSON.parse(typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as any)) } catch { return }
70
+ this.handleMessage(msg)
71
+ }
72
+ this.ws.onclose = () => {
73
+ this.authenticated = false
74
+ this.log('warn', 'WebSocket closed')
75
+ this.scheduleReconnect()
76
+ }
77
+ this.ws.onerror = () => {
78
+ // onclose will follow
79
+ }
80
+ }
81
+
82
+ private scheduleReconnect() {
83
+ if (this.reconnectTimer) return
84
+ const delay = Math.min(60_000, 1000 * Math.pow(2, Math.min(this.reconnectAttempts, 6)))
85
+ this.reconnectAttempts++
86
+ this.log('info', `reconnecting in ${delay}ms`)
87
+ this.reconnectTimer = setTimeout(() => {
88
+ this.reconnectTimer = null
89
+ this.connect()
90
+ }, delay)
91
+ }
92
+
93
+ private send(msg: OutboundMsg) {
94
+ if (this.ws?.readyState === WebSocket.OPEN) {
95
+ try { this.ws.send(JSON.stringify(msg)) } catch {}
96
+ }
97
+ }
98
+
99
+ private log(level: string, message: string, runId?: string) {
100
+ console.log(`[${level}] ${message}`)
101
+ this.send({ type: 'supervisor.log', level, message, run_id: runId, ts: new Date().toISOString() })
102
+ }
103
+
104
+ private async handleMessage(msg: any) {
105
+ if (msg.type === 'ping') { this.send({ type: 'pong' }); return }
106
+ if (msg.type === 'auth_ok') {
107
+ this.authenticated = true
108
+ this.log('info', 'authenticated; sending hello')
109
+ this.send({
110
+ type: 'supervisor.hello',
111
+ version: VERSION,
112
+ os: `${platform()} ${release()}`,
113
+ hostname: hostname(),
114
+ roots: this.cfg.roots,
115
+ capabilities: ['supervisor', 'agent'],
116
+ })
117
+ return
118
+ }
119
+ if (msg.type === 'auth_error') {
120
+ this.log('error', `auth failed: ${msg.error}`)
121
+ try { this.ws?.close() } catch {}
122
+ return
123
+ }
124
+
125
+ if (!this.authenticated) return
126
+
127
+ switch (msg.type) {
128
+ case 'repo.scan': await this.onRepoScan(msg); break
129
+ case 'repo.clone': await this.onRepoClone(msg); break
130
+ case 'repo.pull': await this.onRepoPull(msg); break
131
+ case 'repo.branch_checkout': await this.onBranchCheckout(msg); break
132
+ case 'session.start': await this.onSessionStart(msg); break
133
+ case 'session.stop': await this.onSessionStop(msg); break
134
+ case 'session.status': this.onSessionStatus(msg); break
135
+ default:
136
+ // unknown
137
+ break
138
+ }
139
+ }
140
+
141
+ private async onRepoScan(msg: { req_id: string }) {
142
+ const repos = scanAll(this.cfg.roots)
143
+ this.send({ type: 'repo.scan_result', req_id: msg.req_id, repos })
144
+ }
145
+
146
+ private async onRepoClone(msg: { req_id: string; clone_url: string; target_path: string; repo_full_name: string }) {
147
+ this.send({ type: 'repo.clone_progress', req_id: msg.req_id, stage: 'cloning' })
148
+ const res = await cloneRepo(msg.clone_url, msg.target_path)
149
+ this.send({ type: 'repo.op_result', req_id: msg.req_id, op: 'clone', ok: res.ok, error: res.error, data: res.data })
150
+ }
151
+
152
+ private async onRepoPull(msg: { req_id: string; repo_path: string; branch: string; clone_url: string }) {
153
+ const res = await pullRepo(msg.repo_path, msg.branch, msg.clone_url)
154
+ this.send({ type: 'repo.op_result', req_id: msg.req_id, op: 'pull', ok: res.ok, error: res.error })
155
+ }
156
+
157
+ private async onBranchCheckout(msg: { req_id: string; repo_path: string; branch: string; create: boolean }) {
158
+ const res = await checkoutBranch(msg.repo_path, msg.branch, msg.create)
159
+ this.send({ type: 'repo.op_result', req_id: msg.req_id, op: 'checkout', ok: res.ok, error: res.error })
160
+ }
161
+
162
+ private async onSessionStart(msg: { run_id: string; repo_path: string; branch?: string; pull?: boolean; initial_prompt?: string; api_key: string; hub_url: string }) {
163
+ // pull/checkout pre-flight (best-effort)
164
+ if (msg.pull && msg.branch) {
165
+ this.log('info', `pre-flight: checkout+pull not implemented w/o tokenized url; skipping`, msg.run_id)
166
+ }
167
+ if (msg.branch) {
168
+ const checkout = await checkoutBranch(msg.repo_path, msg.branch, false)
169
+ if (!checkout.ok) {
170
+ this.log('warn', `checkout failed: ${checkout.error}`, msg.run_id)
171
+ }
172
+ }
173
+ await this.pm.start({
174
+ runId: msg.run_id,
175
+ repoPath: msg.repo_path,
176
+ branch: msg.branch ?? null,
177
+ initialPrompt: msg.initial_prompt ?? null,
178
+ apiKey: this.cfg.apiKey,
179
+ hubUrl: this.cfg.hubUrl,
180
+ })
181
+ }
182
+
183
+ private async onSessionStop(msg: { run_id: string; reason: string }) {
184
+ await this.pm.stop(msg.reason)
185
+ }
186
+
187
+ private onSessionStatus(msg: { req_id: string }) {
188
+ this.send({
189
+ type: 'supervisor.state',
190
+ state: this.pm.currentState,
191
+ run_id: this.pm.currentRunId,
192
+ repo_path: this.pm.currentRepoPath,
193
+ })
194
+ }
195
+ }
package/src/index.ts ADDED
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env bun
2
+ import { loadConfig, saveConfig, parseRoots, CONFIG_PATH } from './config'
3
+ import { SupervisorClient } from './hub-client'
4
+ import { installService, uninstallService, statusService, SERVICE_NAME } from './nssm-installer'
5
+ import { scanAll } from './repo-scanner'
6
+
7
+ const VERSION = '0.1.0'
8
+
9
+ function parseArgs(argv: string[]): { cmd: string; flags: Record<string, string | boolean> } {
10
+ const cmd = argv[0] || 'help'
11
+ const flags: Record<string, string | boolean> = {}
12
+ for (let i = 1; i < argv.length; i++) {
13
+ const a = argv[i]
14
+ if (a.startsWith('--')) {
15
+ if (a.includes('=')) {
16
+ const [k, ...v] = a.split('=')
17
+ flags[k.slice(2)] = v.join('=')
18
+ } else if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
19
+ flags[a.slice(2)] = argv[++i]
20
+ } else {
21
+ flags[a.slice(2)] = true
22
+ }
23
+ }
24
+ }
25
+ return { cmd, flags }
26
+ }
27
+
28
+ function printHelp() {
29
+ console.log(`remo-code-supervisor v${VERSION}
30
+
31
+ Usage:
32
+ npx remo-code-supervisor install --api-key <olx_...> [--roots <paths>] [--hub-url <url>] [--service-user <user> --service-password <pwd>]
33
+ npx remo-code-supervisor uninstall
34
+ npx remo-code-supervisor run # foreground (used by the Windows service)
35
+ npx remo-code-supervisor status
36
+ npx remo-code-supervisor scan # one-shot: list discovered repos
37
+
38
+ Notes:
39
+ - --roots is comma- or semicolon-separated; e.g. "C:\\Users\\artic\\GitHub"
40
+ - On Windows, install creates an NSSM-backed service that auto-starts at boot.
41
+ Provide --service-user (e.g. .\\artic) and --service-password to run the
42
+ service as your own Windows user so SSH keys, gh auth, and ~/.config remain reachable.
43
+ - Config file: ${CONFIG_PATH}
44
+ `)
45
+ }
46
+
47
+ async function main() {
48
+ const { cmd, flags } = parseArgs(process.argv.slice(2))
49
+
50
+ if (cmd === 'help' || cmd === '-h' || cmd === '--help') {
51
+ printHelp(); return
52
+ }
53
+
54
+ if (cmd === 'install') {
55
+ const apiKey = (flags['api-key'] as string) || ''
56
+ if (!apiKey) { console.error('error: --api-key required'); process.exit(1) }
57
+ const hubUrl = (flags['hub-url'] as string) || undefined
58
+ const roots = parseRoots(flags['roots'] as string | undefined)
59
+ saveConfig({ apiKey, hubUrl, roots })
60
+ console.log(`[install] config saved to ${CONFIG_PATH}`)
61
+ const serviceUser = flags['service-user'] as string | undefined
62
+ const servicePassword = flags['service-password'] as string | undefined
63
+ try {
64
+ await installService({ apiKey, hubUrl, roots, serviceUser, servicePassword })
65
+ } catch (err: any) {
66
+ console.error(`[install] ${err.message}`)
67
+ console.error(`[install] You can still run the supervisor manually with: npx remo-code-supervisor run`)
68
+ process.exit(2)
69
+ }
70
+ return
71
+ }
72
+
73
+ if (cmd === 'uninstall') {
74
+ await uninstallService()
75
+ return
76
+ }
77
+
78
+ if (cmd === 'status') {
79
+ const s = await statusService().catch(() => 'unknown')
80
+ console.log(`service: ${s}`)
81
+ console.log(`config: ${CONFIG_PATH}`)
82
+ return
83
+ }
84
+
85
+ if (cmd === 'scan') {
86
+ const cfg = loadConfig()
87
+ const repos = scanAll(cfg.roots)
88
+ console.log(JSON.stringify(repos, null, 2))
89
+ return
90
+ }
91
+
92
+ if (cmd === 'run') {
93
+ const cfg = loadConfig()
94
+ console.log(`[run] remo-code-supervisor v${VERSION}`)
95
+ console.log(`[run] hub=${cfg.hubUrl} roots=${cfg.roots.length}`)
96
+ const client = new SupervisorClient(cfg)
97
+ client.connect()
98
+ process.on('SIGINT', () => { console.log('shutting down'); process.exit(0) })
99
+ process.on('SIGTERM', () => { console.log('shutting down'); process.exit(0) })
100
+ // Keep alive
101
+ await new Promise(() => {})
102
+ return
103
+ }
104
+
105
+ console.error(`unknown command: ${cmd}`)
106
+ printHelp()
107
+ process.exit(1)
108
+ }
109
+
110
+ main().catch((err) => {
111
+ console.error('fatal:', err.message)
112
+ process.exit(1)
113
+ })
@@ -0,0 +1,104 @@
1
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs'
2
+ import { join } from 'path'
3
+ import { homedir, platform } from 'os'
4
+
5
+ export const NSSM_DIR = join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'remo-code')
6
+ export const LOG_DIR = join(NSSM_DIR, 'logs')
7
+ export const NSSM_PATH = join(NSSM_DIR, 'nssm.exe')
8
+ export const SERVICE_NAME = 'RemoCodeSupervisor'
9
+
10
+ const NSSM_DOWNLOAD = 'https://nssm.cc/release/nssm-2.24.zip'
11
+
12
+ async function nssm(args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
13
+ const proc = Bun.spawn([NSSM_PATH, ...args], { stdout: 'pipe', stderr: 'pipe' })
14
+ const code = await proc.exited
15
+ const stdout = await new Response(proc.stdout).text()
16
+ const stderr = await new Response(proc.stderr).text()
17
+ return { code: code as number, stdout, stderr }
18
+ }
19
+
20
+ export async function ensureNssm(): Promise<boolean> {
21
+ if (existsSync(NSSM_PATH)) return true
22
+ mkdirSync(NSSM_DIR, { recursive: true })
23
+ mkdirSync(LOG_DIR, { recursive: true })
24
+ console.log(`[install] NSSM not found at ${NSSM_PATH}`)
25
+ console.log(`[install] Please download NSSM from ${NSSM_DOWNLOAD}, extract nssm.exe (win64 version), and place it at ${NSSM_PATH}`)
26
+ console.log(`[install] Then re-run: npx remo-code-supervisor install`)
27
+ return false
28
+ }
29
+
30
+ function bunPath(): string {
31
+ // Best-effort: resolve from Bun.argv0 or PATH
32
+ return process.execPath
33
+ }
34
+
35
+ function entryScriptPath(): string {
36
+ // For `npx remo-code-supervisor install`, the script we want the service to run is this same package's run command.
37
+ // We resolve the supervisor's own bin script by walking up from process.argv[1].
38
+ // Fallback: just shell out to `npx` style.
39
+ return process.argv[1] || ''
40
+ }
41
+
42
+ export interface InstallOptions {
43
+ apiKey: string
44
+ hubUrl?: string
45
+ roots: string[]
46
+ serviceUser?: string
47
+ servicePassword?: string
48
+ }
49
+
50
+ export async function installService(opts: InstallOptions): Promise<void> {
51
+ if (platform() !== 'win32') {
52
+ throw new Error('Service install currently supports Windows only. Use `remo-code-supervisor run` to run in foreground on other OSes.')
53
+ }
54
+ const ok = await ensureNssm()
55
+ if (!ok) throw new Error('NSSM not installed')
56
+
57
+ mkdirSync(LOG_DIR, { recursive: true })
58
+
59
+ const bun = bunPath()
60
+ const entry = entryScriptPath()
61
+
62
+ // Remove existing service first (idempotent)
63
+ await nssm(['stop', SERVICE_NAME]).catch(() => {})
64
+ await nssm(['remove', SERVICE_NAME, 'confirm']).catch(() => {})
65
+
66
+ let r = await nssm(['install', SERVICE_NAME, bun, entry, 'run'])
67
+ if (r.code !== 0) throw new Error(`nssm install failed: ${r.stderr || r.stdout}`)
68
+
69
+ await nssm(['set', SERVICE_NAME, 'DisplayName', 'Remo Code Supervisor'])
70
+ await nssm(['set', SERVICE_NAME, 'Description', 'Remote-control supervisor for Claude Code sessions'])
71
+ await nssm(['set', SERVICE_NAME, 'Start', 'SERVICE_AUTO_START'])
72
+ await nssm(['set', SERVICE_NAME, 'AppStdout', join(LOG_DIR, 'stdout.log')])
73
+ await nssm(['set', SERVICE_NAME, 'AppStderr', join(LOG_DIR, 'stderr.log')])
74
+ await nssm(['set', SERVICE_NAME, 'AppRotateFiles', '1'])
75
+ await nssm(['set', SERVICE_NAME, 'AppRotateBytes', '10485760'])
76
+
77
+ if (opts.serviceUser && opts.servicePassword) {
78
+ const r2 = await nssm(['set', SERVICE_NAME, 'ObjectName', opts.serviceUser, opts.servicePassword])
79
+ if (r2.code !== 0) throw new Error(`nssm set ObjectName failed: ${r2.stderr || r2.stdout}`)
80
+ }
81
+
82
+ const r3 = await nssm(['start', SERVICE_NAME])
83
+ if (r3.code !== 0) throw new Error(`nssm start failed: ${r3.stderr || r3.stdout}`)
84
+ console.log(`[install] Service ${SERVICE_NAME} installed and started.`)
85
+ console.log(`[install] Logs: ${LOG_DIR}`)
86
+ }
87
+
88
+ export async function uninstallService() {
89
+ if (platform() !== 'win32') return
90
+ if (!existsSync(NSSM_PATH)) {
91
+ console.log('[uninstall] nssm not present; nothing to do')
92
+ return
93
+ }
94
+ await nssm(['stop', SERVICE_NAME]).catch(() => {})
95
+ await nssm(['remove', SERVICE_NAME, 'confirm']).catch(() => {})
96
+ console.log(`[uninstall] Service ${SERVICE_NAME} removed.`)
97
+ }
98
+
99
+ export async function statusService(): Promise<string> {
100
+ if (platform() !== 'win32') return 'not-windows'
101
+ if (!existsSync(NSSM_PATH)) return 'nssm-missing'
102
+ const r = await nssm(['status', SERVICE_NAME])
103
+ return r.stdout.trim() || 'unknown'
104
+ }
@@ -0,0 +1,189 @@
1
+ import type { Subprocess } from 'bun'
2
+
3
+ export type ProcState = 'idle' | 'starting' | 'running' | 'stopping' | 'crashed' | 'stopped'
4
+
5
+ export interface RunSpec {
6
+ runId: string
7
+ repoPath: string
8
+ branch: string | null
9
+ initialPrompt: string | null
10
+ apiKey: string
11
+ hubUrl: string
12
+ }
13
+
14
+ export interface ProcessManagerCallbacks {
15
+ onStateChange: (state: ProcState, info: { runId?: string; repoPath?: string; pid?: number; restartCount?: number; lastExit?: { code: number | null; reason: string; stderrTail?: string } }) => void
16
+ onLog: (level: 'info' | 'warn' | 'error', message: string, runId?: string) => void
17
+ }
18
+
19
+ const BACKOFF_SCHEDULE = [1000, 2000, 4000, 8000, 16000, 30000]
20
+ const CIRCUIT_WINDOW_MS = 10 * 60_000
21
+ const CIRCUIT_THRESHOLD = 5
22
+
23
+ export class ProcessManager {
24
+ private state: ProcState = 'idle'
25
+ private proc: Subprocess | null = null
26
+ private currentRun: RunSpec | null = null
27
+ private restartCount = 0
28
+ private recentCrashes: number[] = []
29
+ private restartTimer: ReturnType<typeof setTimeout> | null = null
30
+ private userStop = false
31
+ private cb: ProcessManagerCallbacks
32
+ private stderrTail: string[] = []
33
+
34
+ constructor(cb: ProcessManagerCallbacks) {
35
+ this.cb = cb
36
+ }
37
+
38
+ get currentState() { return this.state }
39
+ get currentRunId() { return this.currentRun?.runId ?? null }
40
+ get currentRepoPath() { return this.currentRun?.repoPath ?? null }
41
+
42
+ async start(spec: RunSpec) {
43
+ if (this.state !== 'idle') {
44
+ this.cb.onLog('warn', `Refusing start: current state=${this.state}`, spec.runId)
45
+ return
46
+ }
47
+ this.currentRun = spec
48
+ this.restartCount = 0
49
+ this.recentCrashes = []
50
+ this.userStop = false
51
+ this.spawnInner()
52
+ }
53
+
54
+ private spawnInner() {
55
+ if (!this.currentRun) return
56
+ const spec = this.currentRun
57
+
58
+ this.setState('starting', { runId: spec.runId, repoPath: spec.repoPath, restartCount: this.restartCount })
59
+
60
+ // Find the claude binary the same way claude-remote-agent does:
61
+ // We spawn 'claude' directly (assumed in PATH on user's machine).
62
+ // Claude's stream-json IO is handled by sending an initial user message via stdin.
63
+ this.stderrTail = []
64
+ const cmd = ['claude', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--dangerously-skip-permissions']
65
+ const env = { ...process.env }
66
+ delete (env as any).ANTHROPIC_API_KEY
67
+
68
+ try {
69
+ this.proc = Bun.spawn(cmd, {
70
+ cwd: spec.repoPath,
71
+ stdin: 'pipe',
72
+ stdout: 'pipe',
73
+ stderr: 'pipe',
74
+ env,
75
+ })
76
+ } catch (err: any) {
77
+ this.cb.onLog('error', `failed to spawn claude: ${err.message}`, spec.runId)
78
+ this.setState('crashed', { runId: spec.runId, repoPath: spec.repoPath, lastExit: { code: null, reason: `spawn_error: ${err.message}` } })
79
+ this.scheduleRestart(null, 'spawn_error')
80
+ return
81
+ }
82
+
83
+ const pid = this.proc.pid
84
+ this.cb.onLog('info', `claude spawned pid=${pid} in ${spec.repoPath}`, spec.runId)
85
+ this.setState('running', { runId: spec.runId, repoPath: spec.repoPath, pid, restartCount: this.restartCount })
86
+
87
+ // If initial prompt provided, send it once the process is ready.
88
+ // We treat "ready" as 3s post-spawn (matches agent pattern).
89
+ if (spec.initialPrompt) {
90
+ setTimeout(() => this.injectInitialPrompt(spec.initialPrompt!), 4000)
91
+ }
92
+
93
+ this.consumeStderr()
94
+
95
+ this.proc.exited.then((code) => {
96
+ const reason = this.userStop ? 'user' : code === 0 ? 'clean' : 'crash'
97
+ const tail = this.stderrTail.slice(-40).join('\n')
98
+ this.cb.onLog(reason === 'crash' ? 'error' : 'info', `claude exited code=${code} reason=${reason}`, spec.runId)
99
+
100
+ if (this.userStop) {
101
+ this.setState('idle', { runId: spec.runId, lastExit: { code: code ?? null, reason, stderrTail: tail } })
102
+ this.currentRun = null
103
+ return
104
+ }
105
+
106
+ if (reason === 'clean') {
107
+ this.setState('idle', { runId: spec.runId, lastExit: { code: code ?? null, reason, stderrTail: tail } })
108
+ this.currentRun = null
109
+ return
110
+ }
111
+
112
+ // crash
113
+ this.recentCrashes.push(Date.now())
114
+ this.recentCrashes = this.recentCrashes.filter((t) => Date.now() - t < CIRCUIT_WINDOW_MS)
115
+ if (this.recentCrashes.length >= CIRCUIT_THRESHOLD) {
116
+ this.cb.onLog('error', `circuit breaker open: ${this.recentCrashes.length} crashes in ${CIRCUIT_WINDOW_MS / 60_000}min — stopping`, spec.runId)
117
+ this.setState('stopped', { runId: spec.runId, lastExit: { code: code ?? null, reason: 'circuit_open', stderrTail: tail } })
118
+ this.currentRun = null
119
+ return
120
+ }
121
+ this.scheduleRestart(code ?? null, 'crash', tail)
122
+ })
123
+ }
124
+
125
+ private scheduleRestart(exitCode: number | null, reason: string, stderrTail = '') {
126
+ if (!this.currentRun) return
127
+ const delay = BACKOFF_SCHEDULE[Math.min(this.restartCount, BACKOFF_SCHEDULE.length - 1)]
128
+ this.restartCount++
129
+ this.cb.onLog('warn', `restarting in ${delay}ms (attempt ${this.restartCount})`, this.currentRun.runId)
130
+ this.setState('crashed', { runId: this.currentRun.runId, lastExit: { code: exitCode, reason, stderrTail } })
131
+ this.restartTimer = setTimeout(() => {
132
+ this.restartTimer = null
133
+ if (!this.currentRun) return
134
+ this.spawnInner()
135
+ }, delay)
136
+ }
137
+
138
+ private injectInitialPrompt(prompt: string) {
139
+ if (!this.proc?.stdin) return
140
+ const msg = JSON.stringify({ type: 'user', message: { role: 'user', content: prompt } })
141
+ try {
142
+ this.proc.stdin.write(msg + '\n')
143
+ this.proc.stdin.flush?.()
144
+ this.cb.onLog('info', `injected initial prompt (${prompt.length} chars)`, this.currentRun?.runId)
145
+ } catch (err: any) {
146
+ this.cb.onLog('warn', `failed to inject initial prompt: ${err.message}`, this.currentRun?.runId)
147
+ }
148
+ }
149
+
150
+ async stop(reason: string) {
151
+ if (this.state === 'idle') return
152
+ this.userStop = true
153
+ if (this.restartTimer) { clearTimeout(this.restartTimer); this.restartTimer = null }
154
+ this.setState('stopping', { runId: this.currentRun?.runId })
155
+ if (this.proc) {
156
+ try { this.proc.kill('SIGINT') } catch {}
157
+ // SIGKILL after 10s
158
+ setTimeout(() => {
159
+ if (this.proc) {
160
+ try { this.proc.kill('SIGKILL') } catch {}
161
+ }
162
+ }, 10_000)
163
+ }
164
+ }
165
+
166
+ private setState(state: ProcState, info: any = {}) {
167
+ this.state = state
168
+ this.cb.onStateChange(state, { restartCount: this.restartCount, ...info })
169
+ }
170
+
171
+ private async consumeStderr() {
172
+ if (!this.proc?.stderr) return
173
+ try {
174
+ const reader = this.proc.stderr.getReader()
175
+ const decoder = new TextDecoder()
176
+ while (true) {
177
+ const { done, value } = await reader.read()
178
+ if (done) break
179
+ const text = decoder.decode(value, { stream: true })
180
+ for (const line of text.split('\n')) {
181
+ if (line.trim()) {
182
+ this.stderrTail.push(line)
183
+ if (this.stderrTail.length > 200) this.stderrTail.shift()
184
+ }
185
+ }
186
+ }
187
+ } catch {}
188
+ }
189
+ }
@@ -0,0 +1,94 @@
1
+ import { readdirSync, statSync, existsSync, readFileSync } from 'fs'
2
+ import { join, basename } from 'path'
3
+
4
+ export interface ScannedRepo {
5
+ path: string
6
+ name: string
7
+ remote: string | null
8
+ branch: string | null
9
+ dirty: boolean
10
+ last_commit: string | null
11
+ }
12
+
13
+ function isDir(p: string): boolean {
14
+ try { return statSync(p).isDirectory() } catch { return false }
15
+ }
16
+
17
+ function readRemote(repoPath: string): string | null {
18
+ try {
19
+ const cfgPath = join(repoPath, '.git', 'config')
20
+ if (!existsSync(cfgPath)) return null
21
+ const cfg = readFileSync(cfgPath, 'utf-8')
22
+ const m = cfg.match(/\[remote "origin"\][^[]*url\s*=\s*(.+)/m)
23
+ return m ? m[1].trim() : null
24
+ } catch { return null }
25
+ }
26
+
27
+ function readBranch(repoPath: string): string | null {
28
+ try {
29
+ const headPath = join(repoPath, '.git', 'HEAD')
30
+ if (!existsSync(headPath)) return null
31
+ const head = readFileSync(headPath, 'utf-8').trim()
32
+ if (head.startsWith('ref: refs/heads/')) return head.slice('ref: refs/heads/'.length)
33
+ return head.slice(0, 12)
34
+ } catch { return null }
35
+ }
36
+
37
+ function gitSync(args: string[], cwd: string, timeoutMs = 5000): string | null {
38
+ try {
39
+ const result = Bun.spawnSync(['git', ...args], {
40
+ cwd,
41
+ stdout: 'pipe',
42
+ stderr: 'ignore',
43
+ timeout: timeoutMs,
44
+ })
45
+ if (result.exitCode !== 0) return null
46
+ return new TextDecoder().decode(result.stdout)
47
+ } catch { return null }
48
+ }
49
+
50
+ function isDirty(repoPath: string): boolean {
51
+ const out = gitSync(['status', '--porcelain'], repoPath)
52
+ if (out == null) return false
53
+ return out.trim().length > 0
54
+ }
55
+
56
+ function lastCommit(repoPath: string): string | null {
57
+ const out = gitSync(['log', '-1', '--pretty=%h%x09%s'], repoPath)
58
+ return out?.trim() || null
59
+ }
60
+
61
+ export function scanRoot(root: string): ScannedRepo[] {
62
+ const out: ScannedRepo[] = []
63
+ if (!isDir(root)) return out
64
+ let entries: string[] = []
65
+ try { entries = readdirSync(root) } catch { return out }
66
+ for (const entry of entries) {
67
+ if (entry.startsWith('.')) continue
68
+ const path = join(root, entry)
69
+ if (!isDir(path)) continue
70
+ if (!existsSync(join(path, '.git'))) continue
71
+ out.push({
72
+ path: path.replace(/\\/g, '/'),
73
+ name: basename(path),
74
+ remote: readRemote(path),
75
+ branch: readBranch(path),
76
+ dirty: isDirty(path),
77
+ last_commit: lastCommit(path),
78
+ })
79
+ }
80
+ return out
81
+ }
82
+
83
+ export function scanAll(roots: string[]): ScannedRepo[] {
84
+ const all: ScannedRepo[] = []
85
+ const seen = new Set<string>()
86
+ for (const r of roots) {
87
+ for (const repo of scanRoot(r)) {
88
+ if (seen.has(repo.path)) continue
89
+ seen.add(repo.path)
90
+ all.push(repo)
91
+ }
92
+ }
93
+ return all.sort((a, b) => a.name.localeCompare(b.name))
94
+ }