feat: move browse state from /tmp to project-local .gstack/

CLI now uses config module for state paths, passes BROWSE_STATE_FILE to
spawned server. Adds version mismatch auto-restart, legacy /tmp cleanup
with PID verification, and removes stale global install fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-13 19:45:54 -05:00
parent 17bd61ee72
commit 594bf32085
+80 -13
View File
@@ -2,22 +2,18 @@
* gstack CLI — thin wrapper that talks to the persistent server
*
* Flow:
* 1. Read /tmp/browse-server.json for port + token
* 1. Read .gstack/browse.json for port + token
* 2. If missing or stale PID → start server in background
* 3. Health check
* 3. Health check + version mismatch detection
* 4. Send command via HTTP POST
* 5. Print response to stdout (or stderr for errors)
*/
import * as fs from 'fs';
import * as path from 'path';
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
const PORT_OFFSET = 45600;
const BROWSE_PORT = process.env.CONDUCTOR_PORT
? parseInt(process.env.CONDUCTOR_PORT, 10) - PORT_OFFSET
: parseInt(process.env.BROWSE_PORT || '0', 10);
const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : '';
const STATE_FILE = process.env.BROWSE_STATE_FILE || `/tmp/browse-server${INSTANCE_SUFFIX}.json`;
const config = resolveConfig();
const MAX_START_WAIT = 8000; // 8 seconds to start
export function resolveServerScript(
@@ -45,8 +41,9 @@ export function resolveServerScript(
}
}
// Legacy fallback for user-level installs
return path.resolve(env.HOME || '/tmp', '.claude/skills/gstack/browse/src/server.ts');
throw new Error(
'Cannot find server.ts. Set BROWSE_SERVER_SCRIPT env or run from the browse source tree.'
);
}
const SERVER_SCRIPT = resolveServerScript();
@@ -57,12 +54,13 @@ interface ServerState {
token: string;
startedAt: string;
serverPath: string;
binaryVersion?: string;
}
// ─── State File ────────────────────────────────────────────────
function readState(): ServerState | null {
try {
const data = fs.readFileSync(STATE_FILE, 'utf-8');
const data = fs.readFileSync(config.stateFile, 'utf-8');
return JSON.parse(data);
} catch {
return null;
@@ -78,15 +76,73 @@ function isProcessAlive(pid: number): boolean {
}
}
// ─── Process Management ─────────────────────────────────────────
async function killServer(pid: number): Promise<void> {
if (!isProcessAlive(pid)) return;
try { process.kill(pid, 'SIGTERM'); } catch { return; }
// Wait up to 2s for graceful shutdown
const deadline = Date.now() + 2000;
while (Date.now() < deadline && isProcessAlive(pid)) {
await Bun.sleep(100);
}
// Force kill if still alive
if (isProcessAlive(pid)) {
try { process.kill(pid, 'SIGKILL'); } catch {}
}
}
/**
* Clean up legacy /tmp/browse-server*.json files from before project-local state.
* Verifies PID ownership before sending signals.
*/
function cleanupLegacyState(): void {
try {
const files = fs.readdirSync('/tmp').filter(f => f.startsWith('browse-server') && f.endsWith('.json'));
for (const file of files) {
const fullPath = `/tmp/${file}`;
try {
const data = JSON.parse(fs.readFileSync(fullPath, 'utf-8'));
if (data.pid && isProcessAlive(data.pid)) {
// Verify this is actually a browse server before killing
const check = Bun.spawnSync(['ps', '-p', String(data.pid), '-o', 'command='], {
stdout: 'pipe', stderr: 'pipe', timeout: 2000,
});
const cmd = check.stdout.toString().trim();
if (cmd.includes('bun') || cmd.includes('server.ts')) {
try { process.kill(data.pid, 'SIGTERM'); } catch {}
}
}
fs.unlinkSync(fullPath);
} catch {
// Best effort — skip files we can't parse or clean up
}
}
// Clean up legacy log files too
const logFiles = fs.readdirSync('/tmp').filter(f =>
f.startsWith('browse-console') || f.startsWith('browse-network') || f.startsWith('browse-dialog')
);
for (const file of logFiles) {
try { fs.unlinkSync(`/tmp/${file}`); } catch {}
}
} catch {
// /tmp read failed — skip legacy cleanup
}
}
// ─── Server Lifecycle ──────────────────────────────────────────
async function startServer(): Promise<ServerState> {
ensureStateDir(config);
// Clean up stale state file
try { fs.unlinkSync(STATE_FILE); } catch {}
try { fs.unlinkSync(config.stateFile); } catch {}
// Start server as detached background process
const proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env },
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile },
});
// Don't hold the CLI open
@@ -120,6 +176,14 @@ async function ensureServer(): Promise<ServerState> {
const state = readState();
if (state && isProcessAlive(state.pid)) {
// Check for binary version mismatch (auto-restart on update)
const currentVersion = readVersionHash();
if (currentVersion && state.binaryVersion && currentVersion !== state.binaryVersion) {
console.error('[browse] Binary updated, restarting server...');
await killServer(state.pid);
return startServer();
}
// Server appears alive — do a health check
try {
const resp = await fetch(`http://127.0.0.1:${state.port}/health`, {
@@ -237,6 +301,9 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
process.exit(0);
}
// One-time cleanup of legacy /tmp state files
cleanupLegacyState();
const command = args[0];
const commandArgs = args.slice(1);