merge: resolve conflicts with origin/main

- VERSION/package.json: keep v0.3.1
- CHANGELOG: include both v0.3.x entries and v0.0.2 from main
- setup: combine main's smart rebuild logic with our Playwright auto-install
- tests: keep main's CLI server script resolution + dead state file tests,
  fix CONDUCTOR_PORT env leak causing port conflicts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-13 00:27:50 -07:00
5 changed files with 146 additions and 31 deletions
+14
View File
@@ -43,6 +43,20 @@
- SKILL.md rewritten as QA-oriented playbook with 10 workflow patterns
- 166 integration tests (was ~63)
## 0.0.2 — 2026-03-12
- Fix project-local `/browse` installs — compiled binary now resolves `server.ts` from its own directory instead of assuming a global install exists
- `setup` rebuilds stale binaries (not just missing ones) and exits non-zero if the build fails
- Fix `chain` command swallowing real errors from write commands (e.g. navigation timeout reported as "Unknown meta command")
- Fix unbounded restart loop in CLI when server crashes repeatedly on the same command
- Cap console/network buffers at 50k entries (ring buffer) instead of growing without bound
- Fix disk flush stopping silently after buffer hits the 50k cap
- Fix `ln -snf` in setup to avoid creating nested symlinks on upgrade
- Use `git fetch && git reset --hard` instead of `git pull` for upgrades (handles force-pushes)
- Simplify install: global-first with optional project copy (replaces submodule approach)
- Restructured README: hero, before/after, demo transcript, troubleshooting section
- Six skills (added `/retro`)
## 0.0.1 — 2026-03-11
Initial release.
+17
View File
@@ -113,6 +113,23 @@ Everything lives inside `.claude/`. Nothing touches your PATH or runs in the bac
---
```
+----------------------------------------------------------------------------+
| |
| Are you a great software engineer who loves to write 10K LOC/day |
| and land 10 PRs a day like Garry? |
| |
| Come work at YC: ycombinator.com/software |
| |
| Extremely competitive salary and equity. |
| Now hiring in San Francisco, Dogpatch District. |
| Come join the revolution. |
| |
+----------------------------------------------------------------------------+
```
---
## How I use these skills
Created by [Garry Tan](https://x.com/garrytan), President & CEO of [Y Combinator](https://www.ycombinator.com/).
+37 -9
View File
@@ -18,13 +18,39 @@ const BROWSE_PORT = process.env.CONDUCTOR_PORT
: 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`;
// When compiled, import.meta.dir is virtual. Use env var or well-known path.
const SERVER_SCRIPT = process.env.BROWSE_SERVER_SCRIPT
|| (import.meta.dir.startsWith('/') && !import.meta.dir.includes('$bunfs')
? path.resolve(import.meta.dir, 'server.ts')
: path.resolve(process.env.HOME || '/tmp', '.claude/skills/gstack/browse/src/server.ts'));
const MAX_START_WAIT = 8000; // 8 seconds to start
export function resolveServerScript(
env: Record<string, string | undefined> = process.env,
metaDir: string = import.meta.dir,
execPath: string = process.execPath
): string {
if (env.BROWSE_SERVER_SCRIPT) {
return env.BROWSE_SERVER_SCRIPT;
}
// Dev mode: cli.ts runs directly from browse/src
if (metaDir.startsWith('/') && !metaDir.includes('$bunfs')) {
const direct = path.resolve(metaDir, 'server.ts');
if (fs.existsSync(direct)) {
return direct;
}
}
// Compiled binary: derive the source tree from browse/dist/browse
if (execPath) {
const adjacent = path.resolve(path.dirname(execPath), '..', 'src', 'server.ts');
if (fs.existsSync(adjacent)) {
return adjacent;
}
}
// Legacy fallback for user-level installs
return path.resolve(env.HOME || '/tmp', '.claude/skills/gstack/browse/src/server.ts');
}
const SERVER_SCRIPT = resolveServerScript();
interface ServerState {
pid: number;
port: number;
@@ -224,7 +250,9 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
await sendCommand(state, command, commandArgs);
}
main().catch((err) => {
console.error(`[browse] ${err.message}`);
process.exit(1);
});
if (import.meta.main) {
main().catch((err) => {
console.error(`[browse] ${err.message}`);
process.exit(1);
});
}
+58 -17
View File
@@ -8,6 +8,7 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { startTestServer } from './test-server';
import { BrowserManager } from '../src/browser-manager';
import { resolveServerScript } from '../src/cli';
import { handleReadCommand } from '../src/read-commands';
import { handleWriteCommand } from '../src/write-commands';
import { handleMetaCommand } from '../src/meta-commands';
@@ -420,34 +421,74 @@ describe('Status', () => {
});
});
// ─── CLI retry guard ────────────────────────────────────────────
// ─── CLI server script resolution ───────────────────────────────
describe('CLI retry guard', () => {
test('sendCommand aborts after repeated connection failures', async () => {
// Use an isolated state file to avoid conflicts with running servers
const stateFile = '/tmp/browse-server-test-retry.json';
fs.writeFileSync(stateFile, JSON.stringify({ port: 1, token: 'fake', pid: 999999 }));
describe('CLI server script resolution', () => {
test('prefers adjacent browse/src/server.ts for compiled project installs', () => {
const root = fs.mkdtempSync('/tmp/gstack-cli-');
const execPath = path.join(root, '.claude/skills/gstack/browse/dist/browse');
const serverPath = path.join(root, '.claude/skills/gstack/browse/src/server.ts');
fs.mkdirSync(path.dirname(execPath), { recursive: true });
fs.mkdirSync(path.dirname(serverPath), { recursive: true });
fs.writeFileSync(serverPath, '// test server\n');
const resolved = resolveServerScript(
{ HOME: path.join(root, 'empty-home') },
'$bunfs/root',
execPath
);
expect(resolved).toBe(serverPath);
fs.rmSync(root, { recursive: true, force: true });
});
});
// ─── CLI lifecycle ──────────────────────────────────────────────
describe('CLI lifecycle', () => {
test('dead state file triggers a clean restart', async () => {
const stateFile = `/tmp/browse-test-state-${Date.now()}.json`;
fs.writeFileSync(stateFile, JSON.stringify({
port: 1,
token: 'fake',
pid: 999999,
}));
const cliPath = path.resolve(__dirname, '../src/cli.ts');
const result = await new Promise<{ code: number; stderr: string }>((resolve) => {
// Build env without CONDUCTOR_PORT/BROWSE_PORT so BROWSE_PORT_START takes effect
const cliEnv: Record<string, string> = {};
for (const [k, v] of Object.entries(process.env)) {
if (k !== 'CONDUCTOR_PORT' && k !== 'BROWSE_PORT' && v !== undefined) cliEnv[k] = v;
}
cliEnv.BROWSE_STATE_FILE = stateFile;
// Use a random high port to avoid conflicts with running servers
cliEnv.BROWSE_PORT_START = String(9600 + Math.floor(Math.random() * 100));
const result = await new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => {
const proc = spawn('bun', ['run', cliPath, 'status'], {
timeout: 15000,
env: {
...process.env,
BROWSE_STATE_FILE: stateFile,
BROWSE_PORT: '1', // Force port 1 (will fail)
},
env: cliEnv,
});
let stdout = '';
let stderr = '';
proc.stdout.on('data', (d) => stdout += d.toString());
proc.stderr.on('data', (d) => stderr += d.toString());
proc.on('close', (code) => resolve({ code: code ?? 1, stderr }));
proc.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
});
// Clean up
try { fs.unlinkSync(stateFile); } catch {}
let restartedPid: number | null = null;
if (fs.existsSync(stateFile)) {
restartedPid = JSON.parse(fs.readFileSync(stateFile, 'utf-8')).pid;
fs.unlinkSync(stateFile);
}
if (restartedPid) {
try { process.kill(restartedPid, 'SIGTERM'); } catch {}
}
// Should fail, not loop forever
expect(result.code).not.toBe(0);
expect(result.code).toBe(0);
expect(result.stdout).toContain('Status: healthy');
expect(result.stderr).toContain('Starting server');
}, 20000);
});
+20 -5
View File
@@ -13,14 +13,29 @@ ensure_playwright_browser() {
) >/dev/null 2>&1
}
# 1. Build browse binary if needed
# 1. Build browse binary if needed (smart rebuild: stale sources, package.json, lock)
NEEDS_BUILD=0
if [ ! -x "$BROWSE_BIN" ]; then
NEEDS_BUILD=1
elif [ -n "$(find "$GSTACK_DIR/browse/src" -type f -newer "$BROWSE_BIN" -print -quit 2>/dev/null)" ]; then
NEEDS_BUILD=1
elif [ "$GSTACK_DIR/package.json" -nt "$BROWSE_BIN" ]; then
NEEDS_BUILD=1
elif [ -f "$GSTACK_DIR/bun.lock" ] && [ "$GSTACK_DIR/bun.lock" -nt "$BROWSE_BIN" ]; then
NEEDS_BUILD=1
fi
if [ "$NEEDS_BUILD" -eq 1 ]; then
echo "Building browse binary..."
cd "$GSTACK_DIR" && bun install && bun run build
(
cd "$GSTACK_DIR"
bun install
bun run build
)
fi
if [ ! -x "$BROWSE_BIN" ]; then
echo "gstack setup failed: browse binary not found at $BROWSE_BIN" >&2
echo "gstack setup failed: browse binary missing at $BROWSE_BIN" >&2
exit 1
fi
@@ -57,12 +72,12 @@ if [ "$SKILLS_BASENAME" = "skills" ]; then
done
echo "gstack ready."
echo " browse: $GSTACK_DIR/browse/dist/browse"
echo " browse: $BROWSE_BIN"
if [ ${#linked[@]} -gt 0 ]; then
echo " linked skills: ${linked[*]}"
fi
else
echo "gstack ready."
echo " browse: $GSTACK_DIR/browse/dist/browse"
echo " browse: $BROWSE_BIN"
echo " (skipped skill symlinks — not inside .claude/skills/)"
fi