opengstack 0.13.7 → 0.13.9
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/bin/opengstack.js +35 -90
- package/package.json +2 -3
- package/scripts/install-skills.js +47 -58
- package/skills/browse/bin/find-browse +21 -0
- package/skills/browse/bin/remote-slug +14 -0
- package/skills/browse/scripts/build-node-server.sh +48 -0
- package/skills/browse/src/activity.ts +208 -0
- package/skills/browse/src/browser-manager.ts +959 -0
- package/skills/browse/src/buffers.ts +137 -0
- package/skills/browse/src/bun-polyfill.cjs +109 -0
- package/skills/browse/src/cli.ts +678 -0
- package/skills/browse/src/commands.ts +128 -0
- package/skills/browse/src/config.ts +150 -0
- package/skills/browse/src/cookie-import-browser.ts +625 -0
- package/skills/browse/src/cookie-picker-routes.ts +230 -0
- package/skills/browse/src/cookie-picker-ui.ts +688 -0
- package/skills/browse/src/find-browse.ts +61 -0
- package/skills/browse/src/meta-commands.ts +550 -0
- package/skills/browse/src/platform.ts +17 -0
- package/skills/browse/src/read-commands.ts +358 -0
- package/skills/browse/src/server.ts +1192 -0
- package/skills/browse/src/sidebar-agent.ts +280 -0
- package/skills/browse/src/sidebar-utils.ts +21 -0
- package/skills/browse/src/snapshot.ts +407 -0
- package/skills/browse/src/url-validation.ts +95 -0
- package/skills/browse/src/write-commands.ts +364 -0
- package/skills/browse/test/activity.test.ts +120 -0
- package/skills/browse/test/adversarial-security.test.ts +32 -0
- package/skills/browse/test/browser-manager-unit.test.ts +17 -0
- package/skills/browse/test/bun-polyfill.test.ts +72 -0
- package/skills/browse/test/commands.test.ts +2075 -0
- package/skills/browse/test/compare-board.test.ts +342 -0
- package/skills/browse/test/config.test.ts +316 -0
- package/skills/browse/test/cookie-import-browser.test.ts +519 -0
- package/skills/browse/test/cookie-picker-routes.test.ts +260 -0
- package/skills/browse/test/file-drop.test.ts +271 -0
- package/skills/browse/test/find-browse.test.ts +50 -0
- package/skills/browse/test/findport.test.ts +191 -0
- package/skills/browse/test/fixtures/basic.html +33 -0
- package/skills/browse/test/fixtures/cursor-interactive.html +22 -0
- package/skills/browse/test/fixtures/dialog.html +15 -0
- package/skills/browse/test/fixtures/empty.html +2 -0
- package/skills/browse/test/fixtures/forms.html +55 -0
- package/skills/browse/test/fixtures/iframe.html +30 -0
- package/skills/browse/test/fixtures/network-idle.html +30 -0
- package/skills/browse/test/fixtures/qa-eval-checkout.html +108 -0
- package/skills/browse/test/fixtures/qa-eval-spa.html +98 -0
- package/skills/browse/test/fixtures/qa-eval.html +51 -0
- package/skills/browse/test/fixtures/responsive.html +49 -0
- package/skills/browse/test/fixtures/snapshot.html +55 -0
- package/skills/browse/test/fixtures/spa.html +24 -0
- package/skills/browse/test/fixtures/states.html +17 -0
- package/skills/browse/test/fixtures/upload.html +25 -0
- package/skills/browse/test/gstack-config.test.ts +138 -0
- package/skills/browse/test/gstack-update-check.test.ts +514 -0
- package/skills/browse/test/handoff.test.ts +235 -0
- package/skills/browse/test/path-validation.test.ts +91 -0
- package/skills/browse/test/platform.test.ts +37 -0
- package/skills/browse/test/server-auth.test.ts +65 -0
- package/skills/browse/test/sidebar-agent-roundtrip.test.ts +226 -0
- package/skills/browse/test/sidebar-agent.test.ts +199 -0
- package/skills/browse/test/sidebar-integration.test.ts +320 -0
- package/skills/browse/test/sidebar-unit.test.ts +96 -0
- package/skills/browse/test/snapshot.test.ts +467 -0
- package/skills/browse/test/state-ttl.test.ts +35 -0
- package/skills/browse/test/test-server.ts +57 -0
- package/skills/browse/test/url-validation.test.ts +72 -0
- package/skills/browse/test/watch.test.ts +129 -0
- package/skills/careful/bin/check-careful.sh +112 -0
- package/skills/cso/ACKNOWLEDGEMENTS.md +14 -0
- package/skills/freeze/bin/check-freeze.sh +79 -0
- package/skills/qa/references/issue-taxonomy.md +85 -0
- package/skills/qa/templates/qa-report-template.md +126 -0
- package/skills/review/TODOS-format.md +62 -0
- package/skills/review/checklist.md +220 -0
- package/skills/review/design-checklist.md +132 -0
- package/skills/review/greptile-triage.md +220 -0
- /package/{autoplan → skills/autoplan}/SKILL.md +0 -0
- /package/{autoplan → skills/autoplan}/SKILL.md.tmpl +0 -0
- /package/{benchmark → skills/benchmark}/SKILL.md +0 -0
- /package/{benchmark → skills/benchmark}/SKILL.md.tmpl +0 -0
- /package/{browse → skills/browse}/SKILL.md +0 -0
- /package/{browse → skills/browse}/SKILL.md.tmpl +0 -0
- /package/{canary → skills/canary}/SKILL.md +0 -0
- /package/{canary → skills/canary}/SKILL.md.tmpl +0 -0
- /package/{careful → skills/careful}/SKILL.md +0 -0
- /package/{careful → skills/careful}/SKILL.md.tmpl +0 -0
- /package/{codex → skills/codex}/SKILL.md +0 -0
- /package/{codex → skills/codex}/SKILL.md.tmpl +0 -0
- /package/{connect-chrome → skills/connect-chrome}/SKILL.md +0 -0
- /package/{connect-chrome → skills/connect-chrome}/SKILL.md.tmpl +0 -0
- /package/{cso → skills/cso}/SKILL.md +0 -0
- /package/{cso → skills/cso}/SKILL.md.tmpl +0 -0
- /package/{design-consultation → skills/design-consultation}/SKILL.md +0 -0
- /package/{design-consultation → skills/design-consultation}/SKILL.md.tmpl +0 -0
- /package/{design-review → skills/design-review}/SKILL.md +0 -0
- /package/{design-review → skills/design-review}/SKILL.md.tmpl +0 -0
- /package/{design-shotgun → skills/design-shotgun}/SKILL.md +0 -0
- /package/{design-shotgun → skills/design-shotgun}/SKILL.md.tmpl +0 -0
- /package/{document-release → skills/document-release}/SKILL.md +0 -0
- /package/{document-release → skills/document-release}/SKILL.md.tmpl +0 -0
- /package/{freeze → skills/freeze}/SKILL.md +0 -0
- /package/{freeze → skills/freeze}/SKILL.md.tmpl +0 -0
- /package/{gstack-upgrade → skills/gstack-upgrade}/SKILL.md +0 -0
- /package/{gstack-upgrade → skills/gstack-upgrade}/SKILL.md.tmpl +0 -0
- /package/{guard → skills/guard}/SKILL.md +0 -0
- /package/{guard → skills/guard}/SKILL.md.tmpl +0 -0
- /package/{investigate → skills/investigate}/SKILL.md +0 -0
- /package/{investigate → skills/investigate}/SKILL.md.tmpl +0 -0
- /package/{land-and-deploy → skills/land-and-deploy}/SKILL.md +0 -0
- /package/{land-and-deploy → skills/land-and-deploy}/SKILL.md.tmpl +0 -0
- /package/{office-hours → skills/office-hours}/SKILL.md +0 -0
- /package/{office-hours → skills/office-hours}/SKILL.md.tmpl +0 -0
- /package/{plan-ceo-review → skills/plan-ceo-review}/SKILL.md +0 -0
- /package/{plan-ceo-review → skills/plan-ceo-review}/SKILL.md.tmpl +0 -0
- /package/{plan-design-review → skills/plan-design-review}/SKILL.md +0 -0
- /package/{plan-design-review → skills/plan-design-review}/SKILL.md.tmpl +0 -0
- /package/{plan-eng-review → skills/plan-eng-review}/SKILL.md +0 -0
- /package/{plan-eng-review → skills/plan-eng-review}/SKILL.md.tmpl +0 -0
- /package/{qa → skills/qa}/SKILL.md +0 -0
- /package/{qa → skills/qa}/SKILL.md.tmpl +0 -0
- /package/{qa-only → skills/qa-only}/SKILL.md +0 -0
- /package/{qa-only → skills/qa-only}/SKILL.md.tmpl +0 -0
- /package/{retro → skills/retro}/SKILL.md +0 -0
- /package/{retro → skills/retro}/SKILL.md.tmpl +0 -0
- /package/{review → skills/review}/SKILL.md +0 -0
- /package/{review → skills/review}/SKILL.md.tmpl +0 -0
- /package/{setup-browser-cookies → skills/setup-browser-cookies}/SKILL.md +0 -0
- /package/{setup-browser-cookies → skills/setup-browser-cookies}/SKILL.md.tmpl +0 -0
- /package/{setup-deploy → skills/setup-deploy}/SKILL.md +0 -0
- /package/{setup-deploy → skills/setup-deploy}/SKILL.md.tmpl +0 -0
- /package/{ship → skills/ship}/SKILL.md +0 -0
- /package/{ship → skills/ship}/SKILL.md.tmpl +0 -0
- /package/{unfreeze → skills/unfreeze}/SKILL.md +0 -0
- /package/{unfreeze → skills/unfreeze}/SKILL.md.tmpl +0 -0
|
@@ -0,0 +1,1192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gstack browse server — persistent Chromium daemon
|
|
3
|
+
*
|
|
4
|
+
* Architecture:
|
|
5
|
+
* Bun.serve HTTP on localhost → routes commands to Playwright
|
|
6
|
+
* Console/network/dialog buffers: CircularBuffer in-memory + async disk flush
|
|
7
|
+
* Chromium crash → server EXITS with clear error (CLI auto-restarts)
|
|
8
|
+
* Auto-shutdown after BROWSE_IDLE_TIMEOUT (default 30 min)
|
|
9
|
+
*
|
|
10
|
+
* State:
|
|
11
|
+
* State file: <project-root>/.gstack/browse.json (set via BROWSE_STATE_FILE env)
|
|
12
|
+
* Log files: <project-root>/.gstack/browse-{console,network,dialog}.log
|
|
13
|
+
* Port: random 10000-60000 (or BROWSE_PORT env for debug override)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { BrowserManager } from './browser-manager';
|
|
17
|
+
import { handleReadCommand } from './read-commands';
|
|
18
|
+
import { handleWriteCommand } from './write-commands';
|
|
19
|
+
import { handleMetaCommand } from './meta-commands';
|
|
20
|
+
import { handleCookiePickerRoute } from './cookie-picker-routes';
|
|
21
|
+
import { sanitizeExtensionUrl } from './sidebar-utils';
|
|
22
|
+
import { COMMAND_DESCRIPTIONS } from './commands';
|
|
23
|
+
import { handleSnapshot, SNAPSHOT_FLAGS } from './snapshot';
|
|
24
|
+
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
|
25
|
+
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
|
|
26
|
+
// Bun.spawn used instead of child_process.spawn (compiled bun binaries
|
|
27
|
+
// fail posix_spawn on all executables including /bin/bash)
|
|
28
|
+
import * as fs from 'fs';
|
|
29
|
+
import * as net from 'net';
|
|
30
|
+
import * as path from 'path';
|
|
31
|
+
import * as crypto from 'crypto';
|
|
32
|
+
|
|
33
|
+
// ─── Config ─────────────────────────────────────────────────────
|
|
34
|
+
const config = resolveConfig();
|
|
35
|
+
ensureStateDir(config);
|
|
36
|
+
|
|
37
|
+
// ─── Auth ───────────────────────────────────────────────────────
|
|
38
|
+
const AUTH_TOKEN = crypto.randomUUID();
|
|
39
|
+
const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10);
|
|
40
|
+
const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10); // 30 min
|
|
41
|
+
// Sidebar chat is always enabled in headed mode (ungated in v0.12.0)
|
|
42
|
+
|
|
43
|
+
function validateAuth(req: Request): boolean {
|
|
44
|
+
const header = req.headers.get('authorization');
|
|
45
|
+
return header === `Bearer ${AUTH_TOKEN}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── Help text (auto-generated from COMMAND_DESCRIPTIONS) ────────
|
|
49
|
+
function generateHelpText(): string {
|
|
50
|
+
// Group commands by category
|
|
51
|
+
const groups = new Map<string, string[]>();
|
|
52
|
+
for (const [cmd, meta] of Object.entries(COMMAND_DESCRIPTIONS)) {
|
|
53
|
+
const display = meta.usage || cmd;
|
|
54
|
+
const list = groups.get(meta.category) || [];
|
|
55
|
+
list.push(display);
|
|
56
|
+
groups.set(meta.category, list);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const categoryOrder = [
|
|
60
|
+
'Navigation', 'Reading', 'Interaction', 'Inspection',
|
|
61
|
+
'Visual', 'Snapshot', 'Meta', 'Tabs', 'Server',
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const lines = ['gstack browse — headless browser for AI agents', '', 'Commands:'];
|
|
65
|
+
for (const cat of categoryOrder) {
|
|
66
|
+
const cmds = groups.get(cat);
|
|
67
|
+
if (!cmds) continue;
|
|
68
|
+
lines.push(` ${(cat + ':').padEnd(15)}${cmds.join(', ')}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Snapshot flags from source of truth
|
|
72
|
+
lines.push('');
|
|
73
|
+
lines.push('Snapshot flags:');
|
|
74
|
+
const flagPairs: string[] = [];
|
|
75
|
+
for (const flag of SNAPSHOT_FLAGS) {
|
|
76
|
+
const label = flag.valueHint ? `${flag.short} ${flag.valueHint}` : flag.short;
|
|
77
|
+
flagPairs.push(`${label} ${flag.long}`);
|
|
78
|
+
}
|
|
79
|
+
// Print two flags per line for compact display
|
|
80
|
+
for (let i = 0; i < flagPairs.length; i += 2) {
|
|
81
|
+
const left = flagPairs[i].padEnd(28);
|
|
82
|
+
const right = flagPairs[i + 1] || '';
|
|
83
|
+
lines.push(` ${left}${right}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return lines.join('\n');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── Buffer (from buffers.ts) ────────────────────────────────────
|
|
90
|
+
import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry } from './buffers';
|
|
91
|
+
export { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry };
|
|
92
|
+
|
|
93
|
+
const CONSOLE_LOG_PATH = config.consoleLog;
|
|
94
|
+
const NETWORK_LOG_PATH = config.networkLog;
|
|
95
|
+
const DIALOG_LOG_PATH = config.dialogLog;
|
|
96
|
+
|
|
97
|
+
// ─── Sidebar Agent (integrated — no separate process) ─────────────
|
|
98
|
+
|
|
99
|
+
interface ChatEntry {
|
|
100
|
+
id: number;
|
|
101
|
+
ts: string;
|
|
102
|
+
role: 'user' | 'assistant' | 'agent';
|
|
103
|
+
message?: string;
|
|
104
|
+
type?: string;
|
|
105
|
+
tool?: string;
|
|
106
|
+
input?: string;
|
|
107
|
+
text?: string;
|
|
108
|
+
error?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface SidebarSession {
|
|
112
|
+
id: string;
|
|
113
|
+
name: string;
|
|
114
|
+
claudeSessionId: string | null;
|
|
115
|
+
worktreePath: string | null;
|
|
116
|
+
createdAt: string;
|
|
117
|
+
lastActiveAt: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const SESSIONS_DIR = path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-sessions');
|
|
121
|
+
const AGENT_TIMEOUT_MS = 300_000; // 5 minutes — multi-page tasks need time
|
|
122
|
+
const MAX_QUEUE = 5;
|
|
123
|
+
|
|
124
|
+
let sidebarSession: SidebarSession | null = null;
|
|
125
|
+
let agentProcess: ChildProcess | null = null;
|
|
126
|
+
let agentStatus: 'idle' | 'processing' | 'hung' = 'idle';
|
|
127
|
+
let agentStartTime: number | null = null;
|
|
128
|
+
let messageQueue: Array<{message: string, ts: string, extensionUrl?: string | null}> = [];
|
|
129
|
+
let currentMessage: string | null = null;
|
|
130
|
+
let chatBuffer: ChatEntry[] = [];
|
|
131
|
+
let chatNextId = 0;
|
|
132
|
+
|
|
133
|
+
// Find the browse binary for the claude subprocess system prompt
|
|
134
|
+
function findBrowseBin(): string {
|
|
135
|
+
const candidates = [
|
|
136
|
+
path.resolve(__dirname, '..', 'dist', 'browse'),
|
|
137
|
+
path.resolve(__dirname, '..', '..', '.claude', 'skills', 'gstack', 'browse', 'dist', 'browse'),
|
|
138
|
+
path.join(process.env.HOME || '', '.claude', 'skills', 'gstack', 'browse', 'dist', 'browse'),
|
|
139
|
+
];
|
|
140
|
+
for (const c of candidates) {
|
|
141
|
+
try { if (fs.existsSync(c)) return c; } catch {}
|
|
142
|
+
}
|
|
143
|
+
return 'browse'; // fallback to PATH
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const BROWSE_BIN = findBrowseBin();
|
|
147
|
+
|
|
148
|
+
function findClaudeBin(): string | null {
|
|
149
|
+
const home = process.env.HOME || '';
|
|
150
|
+
const candidates = [
|
|
151
|
+
// Conductor app bundled binary (not a symlink — works reliably)
|
|
152
|
+
path.join(home, 'Library', 'Application Support', 'com.conductor.app', 'bin', 'claude'),
|
|
153
|
+
// Direct versioned binary (not a symlink)
|
|
154
|
+
...(() => {
|
|
155
|
+
try {
|
|
156
|
+
const versionsDir = path.join(home, '.local', 'share', 'claude', 'versions');
|
|
157
|
+
const entries = fs.readdirSync(versionsDir).filter(e => /^\d/.test(e)).sort().reverse();
|
|
158
|
+
return entries.map(e => path.join(versionsDir, e));
|
|
159
|
+
} catch { return []; }
|
|
160
|
+
})(),
|
|
161
|
+
// Standard install (symlink — resolve it)
|
|
162
|
+
path.join(home, '.local', 'bin', 'claude'),
|
|
163
|
+
'/usr/local/bin/claude',
|
|
164
|
+
'/opt/homebrew/bin/claude',
|
|
165
|
+
];
|
|
166
|
+
// Also check if 'claude' is in current PATH
|
|
167
|
+
try {
|
|
168
|
+
const proc = Bun.spawnSync(['which', 'claude'], { stdout: 'pipe', stderr: 'pipe', timeout: 2000 });
|
|
169
|
+
if (proc.exitCode === 0) {
|
|
170
|
+
const p = proc.stdout.toString().trim();
|
|
171
|
+
if (p) candidates.unshift(p);
|
|
172
|
+
}
|
|
173
|
+
} catch {}
|
|
174
|
+
for (const c of candidates) {
|
|
175
|
+
try {
|
|
176
|
+
if (!fs.existsSync(c)) continue;
|
|
177
|
+
// Resolve symlinks — posix_spawn can fail on symlinks in compiled bun binaries
|
|
178
|
+
return fs.realpathSync(c);
|
|
179
|
+
} catch {}
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function shortenPath(str: string): string {
|
|
185
|
+
return str
|
|
186
|
+
.replace(new RegExp(BROWSE_BIN.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '$B')
|
|
187
|
+
.replace(/\/Users\/[^/]+/g, '~')
|
|
188
|
+
.replace(/\/conductor\/workspaces\/[^/]+\/[^/]+/g, '')
|
|
189
|
+
.replace(/\.claude\/skills\/gstack\//g, '')
|
|
190
|
+
.replace(/browse\/dist\/browse/g, '$B');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function summarizeToolInput(tool: string, input: any): string {
|
|
194
|
+
if (!input) return '';
|
|
195
|
+
if (tool === 'Bash' && input.command) {
|
|
196
|
+
let cmd = shortenPath(input.command);
|
|
197
|
+
return cmd.length > 80 ? cmd.slice(0, 80) + '…' : cmd;
|
|
198
|
+
}
|
|
199
|
+
if (tool === 'Read' && input.file_path) return shortenPath(input.file_path);
|
|
200
|
+
if (tool === 'Edit' && input.file_path) return shortenPath(input.file_path);
|
|
201
|
+
if (tool === 'Write' && input.file_path) return shortenPath(input.file_path);
|
|
202
|
+
if (tool === 'Grep' && input.pattern) return `/${input.pattern}/`;
|
|
203
|
+
if (tool === 'Glob' && input.pattern) return input.pattern;
|
|
204
|
+
try { return shortenPath(JSON.stringify(input)).slice(0, 60); } catch { return ''; }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function addChatEntry(entry: Omit<ChatEntry, 'id'>): ChatEntry {
|
|
208
|
+
const full: ChatEntry = { ...entry, id: chatNextId++ };
|
|
209
|
+
chatBuffer.push(full);
|
|
210
|
+
// Persist to disk (best-effort)
|
|
211
|
+
if (sidebarSession) {
|
|
212
|
+
const chatFile = path.join(SESSIONS_DIR, sidebarSession.id, 'chat.jsonl');
|
|
213
|
+
try { fs.appendFileSync(chatFile, JSON.stringify(full) + '\n'); } catch {}
|
|
214
|
+
}
|
|
215
|
+
return full;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function loadSession(): SidebarSession | null {
|
|
219
|
+
try {
|
|
220
|
+
const activeFile = path.join(SESSIONS_DIR, 'active.json');
|
|
221
|
+
const activeData = JSON.parse(fs.readFileSync(activeFile, 'utf-8'));
|
|
222
|
+
const sessionFile = path.join(SESSIONS_DIR, activeData.id, 'session.json');
|
|
223
|
+
const session = JSON.parse(fs.readFileSync(sessionFile, 'utf-8')) as SidebarSession;
|
|
224
|
+
// Load chat history
|
|
225
|
+
const chatFile = path.join(SESSIONS_DIR, session.id, 'chat.jsonl');
|
|
226
|
+
try {
|
|
227
|
+
const lines = fs.readFileSync(chatFile, 'utf-8').split('\n').filter(Boolean);
|
|
228
|
+
chatBuffer = lines.map(line => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);
|
|
229
|
+
chatNextId = chatBuffer.length > 0 ? Math.max(...chatBuffer.map(e => e.id)) + 1 : 0;
|
|
230
|
+
} catch {}
|
|
231
|
+
return session;
|
|
232
|
+
} catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Create a git worktree for session isolation.
|
|
239
|
+
* Falls back to null (use main cwd) if:
|
|
240
|
+
* - not in a git repo
|
|
241
|
+
* - git worktree add fails (submodules, LFS, permissions)
|
|
242
|
+
* - worktree dir already exists (collision from prior crash)
|
|
243
|
+
*/
|
|
244
|
+
function createWorktree(sessionId: string): string | null {
|
|
245
|
+
try {
|
|
246
|
+
// Check if we're in a git repo
|
|
247
|
+
const gitCheck = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], {
|
|
248
|
+
stdout: 'pipe', stderr: 'pipe', timeout: 3000,
|
|
249
|
+
});
|
|
250
|
+
if (gitCheck.exitCode !== 0) return null;
|
|
251
|
+
const repoRoot = gitCheck.stdout.toString().trim();
|
|
252
|
+
|
|
253
|
+
const worktreeDir = path.join(process.env.HOME || '/tmp', '.gstack', 'worktrees', sessionId.slice(0, 8));
|
|
254
|
+
|
|
255
|
+
// Clean up if dir exists from prior crash
|
|
256
|
+
if (fs.existsSync(worktreeDir)) {
|
|
257
|
+
Bun.spawnSync(['git', 'worktree', 'remove', '--force', worktreeDir], {
|
|
258
|
+
cwd: repoRoot, stdout: 'pipe', stderr: 'pipe', timeout: 5000,
|
|
259
|
+
});
|
|
260
|
+
try { fs.rmSync(worktreeDir, { recursive: true, force: true }); } catch {}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Get current branch/commit
|
|
264
|
+
const headCheck = Bun.spawnSync(['git', 'rev-parse', 'HEAD'], {
|
|
265
|
+
cwd: repoRoot, stdout: 'pipe', stderr: 'pipe', timeout: 3000,
|
|
266
|
+
});
|
|
267
|
+
if (headCheck.exitCode !== 0) return null;
|
|
268
|
+
const head = headCheck.stdout.toString().trim();
|
|
269
|
+
|
|
270
|
+
// Create worktree (detached HEAD — no branch conflicts)
|
|
271
|
+
const result = Bun.spawnSync(['git', 'worktree', 'add', '--detach', worktreeDir, head], {
|
|
272
|
+
cwd: repoRoot, stdout: 'pipe', stderr: 'pipe', timeout: 10000,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
if (result.exitCode !== 0) {
|
|
276
|
+
console.log(`[browse] Worktree creation failed: ${result.stderr.toString().trim()}`);
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
console.log(`[browse] Created worktree: ${worktreeDir}`);
|
|
281
|
+
return worktreeDir;
|
|
282
|
+
} catch (err: any) {
|
|
283
|
+
console.log(`[browse] Worktree creation error: ${err.message}`);
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function removeWorktree(worktreePath: string | null): void {
|
|
289
|
+
if (!worktreePath) return;
|
|
290
|
+
try {
|
|
291
|
+
const gitCheck = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], {
|
|
292
|
+
stdout: 'pipe', stderr: 'pipe', timeout: 3000,
|
|
293
|
+
});
|
|
294
|
+
if (gitCheck.exitCode === 0) {
|
|
295
|
+
Bun.spawnSync(['git', 'worktree', 'remove', '--force', worktreePath], {
|
|
296
|
+
cwd: gitCheck.stdout.toString().trim(), stdout: 'pipe', stderr: 'pipe', timeout: 5000,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
// Cleanup dir if git worktree remove didn't
|
|
300
|
+
try { fs.rmSync(worktreePath, { recursive: true, force: true }); } catch {}
|
|
301
|
+
} catch {}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function createSession(): SidebarSession {
|
|
305
|
+
const id = crypto.randomUUID();
|
|
306
|
+
const worktreePath = createWorktree(id);
|
|
307
|
+
const session: SidebarSession = {
|
|
308
|
+
id,
|
|
309
|
+
name: 'Chrome sidebar',
|
|
310
|
+
claudeSessionId: null,
|
|
311
|
+
worktreePath,
|
|
312
|
+
createdAt: new Date().toISOString(),
|
|
313
|
+
lastActiveAt: new Date().toISOString(),
|
|
314
|
+
};
|
|
315
|
+
const sessionDir = path.join(SESSIONS_DIR, id);
|
|
316
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
317
|
+
fs.writeFileSync(path.join(sessionDir, 'session.json'), JSON.stringify(session, null, 2));
|
|
318
|
+
fs.writeFileSync(path.join(sessionDir, 'chat.jsonl'), '');
|
|
319
|
+
fs.writeFileSync(path.join(SESSIONS_DIR, 'active.json'), JSON.stringify({ id }));
|
|
320
|
+
chatBuffer = [];
|
|
321
|
+
chatNextId = 0;
|
|
322
|
+
return session;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function saveSession(): void {
|
|
326
|
+
if (!sidebarSession) return;
|
|
327
|
+
sidebarSession.lastActiveAt = new Date().toISOString();
|
|
328
|
+
const sessionFile = path.join(SESSIONS_DIR, sidebarSession.id, 'session.json');
|
|
329
|
+
try { fs.writeFileSync(sessionFile, JSON.stringify(sidebarSession, null, 2)); } catch {}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function listSessions(): Array<SidebarSession & { chatLines: number }> {
|
|
333
|
+
try {
|
|
334
|
+
const dirs = fs.readdirSync(SESSIONS_DIR).filter(d => d !== 'active.json');
|
|
335
|
+
return dirs.map(d => {
|
|
336
|
+
try {
|
|
337
|
+
const session = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, d, 'session.json'), 'utf-8'));
|
|
338
|
+
let chatLines = 0;
|
|
339
|
+
try { chatLines = fs.readFileSync(path.join(SESSIONS_DIR, d, 'chat.jsonl'), 'utf-8').split('\n').filter(Boolean).length; } catch {}
|
|
340
|
+
return { ...session, chatLines };
|
|
341
|
+
} catch { return null; }
|
|
342
|
+
}).filter(Boolean);
|
|
343
|
+
} catch { return []; }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function processAgentEvent(event: any): void {
|
|
347
|
+
if (event.type === 'system' && event.session_id && sidebarSession && !sidebarSession.claudeSessionId) {
|
|
348
|
+
// Capture session_id from first claude init event for --resume
|
|
349
|
+
sidebarSession.claudeSessionId = event.session_id;
|
|
350
|
+
saveSession();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (event.type === 'assistant' && event.message?.content) {
|
|
354
|
+
for (const block of event.message.content) {
|
|
355
|
+
if (block.type === 'tool_use') {
|
|
356
|
+
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'tool_use', tool: block.name, input: summarizeToolInput(block.name, block.input) });
|
|
357
|
+
} else if (block.type === 'text' && block.text) {
|
|
358
|
+
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'text', text: block.text });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (event.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
|
|
364
|
+
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'tool_use', tool: event.content_block.name, input: summarizeToolInput(event.content_block.name, event.content_block.input) });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta' && event.delta.text) {
|
|
368
|
+
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'text_delta', text: event.delta.text });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (event.type === 'result') {
|
|
372
|
+
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'result', text: event.text || event.result || '' });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function spawnClaude(userMessage: string, extensionUrl?: string | null): void {
|
|
377
|
+
agentStatus = 'processing';
|
|
378
|
+
agentStartTime = Date.now();
|
|
379
|
+
currentMessage = userMessage;
|
|
380
|
+
|
|
381
|
+
// Prefer the URL from the Chrome extension (what the user actually sees)
|
|
382
|
+
// over Playwright's page.url() which can be stale in headed mode.
|
|
383
|
+
const sanitizedExtUrl = sanitizeExtensionUrl(extensionUrl);
|
|
384
|
+
const playwrightUrl = browserManager.getCurrentUrl() || 'about:blank';
|
|
385
|
+
const pageUrl = sanitizedExtUrl || playwrightUrl;
|
|
386
|
+
const B = BROWSE_BIN;
|
|
387
|
+
const systemPrompt = [
|
|
388
|
+
'You are a browser assistant running in a Chrome sidebar.',
|
|
389
|
+
`The user is currently viewing: ${pageUrl}`,
|
|
390
|
+
`Browse binary: ${B}`,
|
|
391
|
+
'',
|
|
392
|
+
'IMPORTANT: You are controlling a SHARED browser. The user may have navigated',
|
|
393
|
+
'manually. Always run `' + B + ' url` first to check the actual current URL.',
|
|
394
|
+
'If it differs from above, the user navigated — work with the ACTUAL page.',
|
|
395
|
+
'Do NOT navigate away from the user\'s current page unless they ask you to.',
|
|
396
|
+
'',
|
|
397
|
+
'Commands (run via bash):',
|
|
398
|
+
` ${B} goto <url> ${B} click <@ref> ${B} fill <@ref> <text>`,
|
|
399
|
+
` ${B} snapshot -i ${B} text ${B} screenshot`,
|
|
400
|
+
` ${B} back ${B} forward ${B} reload`,
|
|
401
|
+
'',
|
|
402
|
+
'Rules: run snapshot -i before clicking. Keep responses SHORT.',
|
|
403
|
+
].join('\n');
|
|
404
|
+
|
|
405
|
+
const prompt = `${systemPrompt}\n\nUser: ${userMessage}`;
|
|
406
|
+
const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose',
|
|
407
|
+
'--allowedTools', 'Bash,Read,Glob,Grep'];
|
|
408
|
+
if (sidebarSession?.claudeSessionId) {
|
|
409
|
+
args.push('--resume', sidebarSession.claudeSessionId);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_start' });
|
|
413
|
+
|
|
414
|
+
// Compiled bun binaries CANNOT spawn external processes (posix_spawn
|
|
415
|
+
// fails with ENOENT on everything, including /bin/bash). Instead,
|
|
416
|
+
// write the command to a queue file that the sidebar-agent process
|
|
417
|
+
// (running as non-compiled bun) picks up and spawns claude.
|
|
418
|
+
const agentQueue = process.env.SIDEBAR_QUEUE_PATH || path.join(process.env.HOME || '/tmp', '.gstack', 'sidebar-agent-queue.jsonl');
|
|
419
|
+
const gstackDir = path.dirname(agentQueue);
|
|
420
|
+
const entry = JSON.stringify({
|
|
421
|
+
ts: new Date().toISOString(),
|
|
422
|
+
message: userMessage,
|
|
423
|
+
prompt,
|
|
424
|
+
args,
|
|
425
|
+
stateFile: config.stateFile,
|
|
426
|
+
cwd: (sidebarSession as any)?.worktreePath || process.cwd(),
|
|
427
|
+
sessionId: sidebarSession?.claudeSessionId || null,
|
|
428
|
+
pageUrl: pageUrl,
|
|
429
|
+
});
|
|
430
|
+
try {
|
|
431
|
+
fs.mkdirSync(gstackDir, { recursive: true });
|
|
432
|
+
fs.appendFileSync(agentQueue, entry + '\n');
|
|
433
|
+
} catch (err: any) {
|
|
434
|
+
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_error', error: `Failed to queue: ${err.message}` });
|
|
435
|
+
agentStatus = 'idle';
|
|
436
|
+
agentStartTime = null;
|
|
437
|
+
currentMessage = null;
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
// The sidebar-agent.ts process polls this file and spawns claude.
|
|
441
|
+
// It POST events back via /sidebar-event which processAgentEvent handles.
|
|
442
|
+
// Agent status transitions happen when we receive agent_done/agent_error events.
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function killAgent(): void {
|
|
446
|
+
if (agentProcess) {
|
|
447
|
+
try { agentProcess.kill('SIGTERM'); } catch {}
|
|
448
|
+
setTimeout(() => { try { agentProcess?.kill('SIGKILL'); } catch {} }, 3000);
|
|
449
|
+
}
|
|
450
|
+
agentProcess = null;
|
|
451
|
+
agentStartTime = null;
|
|
452
|
+
currentMessage = null;
|
|
453
|
+
agentStatus = 'idle';
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Agent health check — detect hung processes
|
|
457
|
+
let agentHealthInterval: ReturnType<typeof setInterval> | null = null;
|
|
458
|
+
function startAgentHealthCheck(): void {
|
|
459
|
+
agentHealthInterval = setInterval(() => {
|
|
460
|
+
if (agentStatus === 'processing' && agentStartTime && Date.now() - agentStartTime > AGENT_TIMEOUT_MS) {
|
|
461
|
+
agentStatus = 'hung';
|
|
462
|
+
console.log(`[browse] Sidebar agent hung (>${AGENT_TIMEOUT_MS / 1000}s)`);
|
|
463
|
+
}
|
|
464
|
+
}, 10000);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Initialize session on startup
|
|
468
|
+
function initSidebarSession(): void {
|
|
469
|
+
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
470
|
+
sidebarSession = loadSession();
|
|
471
|
+
if (!sidebarSession) {
|
|
472
|
+
sidebarSession = createSession();
|
|
473
|
+
}
|
|
474
|
+
console.log(`[browse] Sidebar session: ${sidebarSession.id} (${chatBuffer.length} chat entries loaded)`);
|
|
475
|
+
startAgentHealthCheck();
|
|
476
|
+
}
|
|
477
|
+
let lastConsoleFlushed = 0;
|
|
478
|
+
let lastNetworkFlushed = 0;
|
|
479
|
+
let lastDialogFlushed = 0;
|
|
480
|
+
let flushInProgress = false;
|
|
481
|
+
|
|
482
|
+
async function flushBuffers() {
|
|
483
|
+
if (flushInProgress) return; // Guard against concurrent flush
|
|
484
|
+
flushInProgress = true;
|
|
485
|
+
|
|
486
|
+
try {
|
|
487
|
+
// Console buffer
|
|
488
|
+
const newConsoleCount = consoleBuffer.totalAdded - lastConsoleFlushed;
|
|
489
|
+
if (newConsoleCount > 0) {
|
|
490
|
+
const entries = consoleBuffer.last(Math.min(newConsoleCount, consoleBuffer.length));
|
|
491
|
+
const lines = entries.map(e =>
|
|
492
|
+
`[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
|
|
493
|
+
).join('\n') + '\n';
|
|
494
|
+
fs.appendFileSync(CONSOLE_LOG_PATH, lines);
|
|
495
|
+
lastConsoleFlushed = consoleBuffer.totalAdded;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Network buffer
|
|
499
|
+
const newNetworkCount = networkBuffer.totalAdded - lastNetworkFlushed;
|
|
500
|
+
if (newNetworkCount > 0) {
|
|
501
|
+
const entries = networkBuffer.last(Math.min(newNetworkCount, networkBuffer.length));
|
|
502
|
+
const lines = entries.map(e =>
|
|
503
|
+
`[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)`
|
|
504
|
+
).join('\n') + '\n';
|
|
505
|
+
fs.appendFileSync(NETWORK_LOG_PATH, lines);
|
|
506
|
+
lastNetworkFlushed = networkBuffer.totalAdded;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Dialog buffer
|
|
510
|
+
const newDialogCount = dialogBuffer.totalAdded - lastDialogFlushed;
|
|
511
|
+
if (newDialogCount > 0) {
|
|
512
|
+
const entries = dialogBuffer.last(Math.min(newDialogCount, dialogBuffer.length));
|
|
513
|
+
const lines = entries.map(e =>
|
|
514
|
+
`[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}`
|
|
515
|
+
).join('\n') + '\n';
|
|
516
|
+
fs.appendFileSync(DIALOG_LOG_PATH, lines);
|
|
517
|
+
lastDialogFlushed = dialogBuffer.totalAdded;
|
|
518
|
+
}
|
|
519
|
+
} catch {
|
|
520
|
+
// Flush failures are non-fatal — buffers are in memory
|
|
521
|
+
} finally {
|
|
522
|
+
flushInProgress = false;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Flush every 1 second
|
|
527
|
+
const flushInterval = setInterval(flushBuffers, 1000);
|
|
528
|
+
|
|
529
|
+
// ─── Idle Timer ────────────────────────────────────────────────
|
|
530
|
+
let lastActivity = Date.now();
|
|
531
|
+
|
|
532
|
+
function resetIdleTimer() {
|
|
533
|
+
lastActivity = Date.now();
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const idleCheckInterval = setInterval(() => {
|
|
537
|
+
if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) {
|
|
538
|
+
console.log(`[browse] Idle for ${IDLE_TIMEOUT_MS / 1000}s, shutting down`);
|
|
539
|
+
shutdown();
|
|
540
|
+
}
|
|
541
|
+
}, 60_000);
|
|
542
|
+
|
|
543
|
+
// ─── Command Sets (from commands.ts — single source of truth) ───
|
|
544
|
+
import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands';
|
|
545
|
+
export { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS };
|
|
546
|
+
|
|
547
|
+
// ─── Server ────────────────────────────────────────────────────
|
|
548
|
+
const browserManager = new BrowserManager();
|
|
549
|
+
let isShuttingDown = false;
|
|
550
|
+
|
|
551
|
+
// Test if a port is available by binding and immediately releasing.
|
|
552
|
+
// Uses net.createServer instead of Bun.serve to avoid a race condition
|
|
553
|
+
// in the Node.js polyfill where listen/close are async but the caller
|
|
554
|
+
// expects synchronous bind semantics. See: #486
|
|
555
|
+
function isPortAvailable(port: number, hostname: string = '127.0.0.1'): Promise<boolean> {
|
|
556
|
+
return new Promise((resolve) => {
|
|
557
|
+
const srv = net.createServer();
|
|
558
|
+
srv.once('error', () => resolve(false));
|
|
559
|
+
srv.listen(port, hostname, () => {
|
|
560
|
+
srv.close(() => resolve(true));
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Find port: explicit BROWSE_PORT, or random in 10000-60000
|
|
566
|
+
async function findPort(): Promise<number> {
|
|
567
|
+
// Explicit port override (for debugging)
|
|
568
|
+
if (BROWSE_PORT) {
|
|
569
|
+
if (await isPortAvailable(BROWSE_PORT)) {
|
|
570
|
+
return BROWSE_PORT;
|
|
571
|
+
}
|
|
572
|
+
throw new Error(`[browse] Port ${BROWSE_PORT} (from BROWSE_PORT env) is in use`);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Random port with retry
|
|
576
|
+
const MIN_PORT = 10000;
|
|
577
|
+
const MAX_PORT = 60000;
|
|
578
|
+
const MAX_RETRIES = 5;
|
|
579
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
580
|
+
const port = MIN_PORT + Math.floor(Math.random() * (MAX_PORT - MIN_PORT));
|
|
581
|
+
if (await isPortAvailable(port)) {
|
|
582
|
+
return port;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
throw new Error(`[browse] No available port after ${MAX_RETRIES} attempts in range ${MIN_PORT}-${MAX_PORT}`);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Translate Playwright errors into actionable messages for AI agents.
|
|
590
|
+
*/
|
|
591
|
+
function wrapError(err: any): string {
|
|
592
|
+
const msg = err.message || String(err);
|
|
593
|
+
// Timeout errors
|
|
594
|
+
if (err.name === 'TimeoutError' || msg.includes('Timeout') || msg.includes('timeout')) {
|
|
595
|
+
if (msg.includes('locator.click') || msg.includes('locator.fill') || msg.includes('locator.hover')) {
|
|
596
|
+
return `Element not found or not interactable within timeout. Check your selector or run 'snapshot' for fresh refs.`;
|
|
597
|
+
}
|
|
598
|
+
if (msg.includes('page.goto') || msg.includes('Navigation')) {
|
|
599
|
+
return `Page navigation timed out. The URL may be unreachable or the page may be loading slowly.`;
|
|
600
|
+
}
|
|
601
|
+
return `Operation timed out: ${msg.split('\n')[0]}`;
|
|
602
|
+
}
|
|
603
|
+
// Multiple elements matched
|
|
604
|
+
if (msg.includes('resolved to') && msg.includes('elements')) {
|
|
605
|
+
return `Selector matched multiple elements. Be more specific or use @refs from 'snapshot'.`;
|
|
606
|
+
}
|
|
607
|
+
// Pass through other errors
|
|
608
|
+
return msg;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function handleCommand(body: any): Promise<Response> {
|
|
612
|
+
const { command, args = [] } = body;
|
|
613
|
+
|
|
614
|
+
if (!command) {
|
|
615
|
+
return new Response(JSON.stringify({ error: 'Missing "command" field' }), {
|
|
616
|
+
status: 400,
|
|
617
|
+
headers: { 'Content-Type': 'application/json' },
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Block mutation commands while watching (read-only observation mode)
|
|
622
|
+
if (browserManager.isWatching() && WRITE_COMMANDS.has(command)) {
|
|
623
|
+
return new Response(JSON.stringify({
|
|
624
|
+
error: 'Cannot run mutation commands while watching. Run `$B watch stop` first.',
|
|
625
|
+
}), {
|
|
626
|
+
status: 400,
|
|
627
|
+
headers: { 'Content-Type': 'application/json' },
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Activity: emit command_start
|
|
632
|
+
const startTime = Date.now();
|
|
633
|
+
emitActivity({
|
|
634
|
+
type: 'command_start',
|
|
635
|
+
command,
|
|
636
|
+
args,
|
|
637
|
+
url: browserManager.getCurrentUrl(),
|
|
638
|
+
tabs: browserManager.getTabCount(),
|
|
639
|
+
mode: browserManager.getConnectionMode(),
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
try {
|
|
643
|
+
let result: string;
|
|
644
|
+
|
|
645
|
+
if (READ_COMMANDS.has(command)) {
|
|
646
|
+
result = await handleReadCommand(command, args, browserManager);
|
|
647
|
+
} else if (WRITE_COMMANDS.has(command)) {
|
|
648
|
+
result = await handleWriteCommand(command, args, browserManager);
|
|
649
|
+
} else if (META_COMMANDS.has(command)) {
|
|
650
|
+
result = await handleMetaCommand(command, args, browserManager, shutdown);
|
|
651
|
+
// Start periodic snapshot interval when watch mode begins
|
|
652
|
+
if (command === 'watch' && args[0] !== 'stop' && browserManager.isWatching()) {
|
|
653
|
+
const watchInterval = setInterval(async () => {
|
|
654
|
+
if (!browserManager.isWatching()) {
|
|
655
|
+
clearInterval(watchInterval);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
try {
|
|
659
|
+
const snapshot = await handleSnapshot(['-i'], browserManager);
|
|
660
|
+
browserManager.addWatchSnapshot(snapshot);
|
|
661
|
+
} catch {
|
|
662
|
+
// Page may be navigating — skip this snapshot
|
|
663
|
+
}
|
|
664
|
+
}, 5000);
|
|
665
|
+
browserManager.watchInterval = watchInterval;
|
|
666
|
+
}
|
|
667
|
+
} else if (command === 'help') {
|
|
668
|
+
const helpText = generateHelpText();
|
|
669
|
+
return new Response(helpText, {
|
|
670
|
+
status: 200,
|
|
671
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
672
|
+
});
|
|
673
|
+
} else {
|
|
674
|
+
return new Response(JSON.stringify({
|
|
675
|
+
error: `Unknown command: ${command}`,
|
|
676
|
+
hint: `Available commands: ${[...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS].sort().join(', ')}`,
|
|
677
|
+
}), {
|
|
678
|
+
status: 400,
|
|
679
|
+
headers: { 'Content-Type': 'application/json' },
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Activity: emit command_end (success)
|
|
684
|
+
emitActivity({
|
|
685
|
+
type: 'command_end',
|
|
686
|
+
command,
|
|
687
|
+
args,
|
|
688
|
+
url: browserManager.getCurrentUrl(),
|
|
689
|
+
duration: Date.now() - startTime,
|
|
690
|
+
status: 'ok',
|
|
691
|
+
result: result,
|
|
692
|
+
tabs: browserManager.getTabCount(),
|
|
693
|
+
mode: browserManager.getConnectionMode(),
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
browserManager.resetFailures();
|
|
697
|
+
return new Response(result, {
|
|
698
|
+
status: 200,
|
|
699
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
700
|
+
});
|
|
701
|
+
} catch (err: any) {
|
|
702
|
+
// Activity: emit command_end (error)
|
|
703
|
+
emitActivity({
|
|
704
|
+
type: 'command_end',
|
|
705
|
+
command,
|
|
706
|
+
args,
|
|
707
|
+
url: browserManager.getCurrentUrl(),
|
|
708
|
+
duration: Date.now() - startTime,
|
|
709
|
+
status: 'error',
|
|
710
|
+
error: err.message,
|
|
711
|
+
tabs: browserManager.getTabCount(),
|
|
712
|
+
mode: browserManager.getConnectionMode(),
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
browserManager.incrementFailures();
|
|
716
|
+
let errorMsg = wrapError(err);
|
|
717
|
+
const hint = browserManager.getFailureHint();
|
|
718
|
+
if (hint) errorMsg += '\n' + hint;
|
|
719
|
+
return new Response(JSON.stringify({ error: errorMsg }), {
|
|
720
|
+
status: 500,
|
|
721
|
+
headers: { 'Content-Type': 'application/json' },
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
async function shutdown() {
|
|
727
|
+
if (isShuttingDown) return;
|
|
728
|
+
isShuttingDown = true;
|
|
729
|
+
|
|
730
|
+
console.log('[browse] Shutting down...');
|
|
731
|
+
// Stop watch mode if active
|
|
732
|
+
if (browserManager.isWatching()) browserManager.stopWatch();
|
|
733
|
+
killAgent();
|
|
734
|
+
messageQueue = [];
|
|
735
|
+
saveSession(); // Persist chat history before exit
|
|
736
|
+
if (sidebarSession?.worktreePath) removeWorktree(sidebarSession.worktreePath);
|
|
737
|
+
if (agentHealthInterval) clearInterval(agentHealthInterval);
|
|
738
|
+
clearInterval(flushInterval);
|
|
739
|
+
clearInterval(idleCheckInterval);
|
|
740
|
+
await flushBuffers(); // Final flush (async now)
|
|
741
|
+
|
|
742
|
+
await browserManager.close();
|
|
743
|
+
|
|
744
|
+
// Clean up Chromium profile locks (prevent SingletonLock on next launch)
|
|
745
|
+
const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
|
|
746
|
+
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
|
|
747
|
+
try { fs.unlinkSync(path.join(profileDir, lockFile)); } catch {}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Clean up state file
|
|
751
|
+
try { fs.unlinkSync(config.stateFile); } catch {}
|
|
752
|
+
|
|
753
|
+
process.exit(0);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Handle signals
|
|
757
|
+
process.on('SIGTERM', shutdown);
|
|
758
|
+
process.on('SIGINT', shutdown);
|
|
759
|
+
// Windows: taskkill /F bypasses SIGTERM, but 'exit' fires for some shutdown paths.
|
|
760
|
+
// Defense-in-depth — primary cleanup is the CLI's stale-state detection via health check.
|
|
761
|
+
if (process.platform === 'win32') {
|
|
762
|
+
process.on('exit', () => {
|
|
763
|
+
try { fs.unlinkSync(config.stateFile); } catch {}
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Emergency cleanup for crashes (OOM, uncaught exceptions, browser disconnect)
|
|
768
|
+
function emergencyCleanup() {
|
|
769
|
+
if (isShuttingDown) return;
|
|
770
|
+
isShuttingDown = true;
|
|
771
|
+
// Kill agent subprocess if running
|
|
772
|
+
try { killAgent(); } catch {}
|
|
773
|
+
// Save session state so chat history persists across crashes
|
|
774
|
+
try { saveSession(); } catch {}
|
|
775
|
+
// Clean Chromium profile locks
|
|
776
|
+
const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
|
|
777
|
+
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
|
|
778
|
+
try { fs.unlinkSync(path.join(profileDir, lockFile)); } catch {}
|
|
779
|
+
}
|
|
780
|
+
try { fs.unlinkSync(config.stateFile); } catch {}
|
|
781
|
+
}
|
|
782
|
+
process.on('uncaughtException', (err) => {
|
|
783
|
+
console.error('[browse] FATAL uncaught exception:', err.message);
|
|
784
|
+
emergencyCleanup();
|
|
785
|
+
process.exit(1);
|
|
786
|
+
});
|
|
787
|
+
process.on('unhandledRejection', (err: any) => {
|
|
788
|
+
console.error('[browse] FATAL unhandled rejection:', err?.message || err);
|
|
789
|
+
emergencyCleanup();
|
|
790
|
+
process.exit(1);
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
// ─── Start ─────────────────────────────────────────────────────
|
|
794
|
+
async function start() {
|
|
795
|
+
// Clear old log files
|
|
796
|
+
try { fs.unlinkSync(CONSOLE_LOG_PATH); } catch {}
|
|
797
|
+
try { fs.unlinkSync(NETWORK_LOG_PATH); } catch {}
|
|
798
|
+
try { fs.unlinkSync(DIALOG_LOG_PATH); } catch {}
|
|
799
|
+
|
|
800
|
+
const port = await findPort();
|
|
801
|
+
|
|
802
|
+
// Launch browser (headless or headed with extension)
|
|
803
|
+
// BROWSE_HEADLESS_SKIP=1 skips browser launch entirely (for HTTP-only testing)
|
|
804
|
+
const skipBrowser = process.env.BROWSE_HEADLESS_SKIP === '1';
|
|
805
|
+
if (!skipBrowser) {
|
|
806
|
+
const headed = process.env.BROWSE_HEADED === '1';
|
|
807
|
+
if (headed) {
|
|
808
|
+
await browserManager.launchHeaded(AUTH_TOKEN);
|
|
809
|
+
console.log(`[browse] Launched headed Chromium with extension`);
|
|
810
|
+
} else {
|
|
811
|
+
await browserManager.launch();
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const startTime = Date.now();
|
|
816
|
+
const server = Bun.serve({
|
|
817
|
+
port,
|
|
818
|
+
hostname: '127.0.0.1',
|
|
819
|
+
fetch: async (req) => {
|
|
820
|
+
const url = new URL(req.url);
|
|
821
|
+
|
|
822
|
+
// Cookie picker routes — HTML page unauthenticated, data/action routes require auth
|
|
823
|
+
if (url.pathname.startsWith('/cookie-picker')) {
|
|
824
|
+
return handleCookiePickerRoute(url, req, browserManager, AUTH_TOKEN);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Health check — no auth required, does NOT reset idle timer
|
|
828
|
+
if (url.pathname === '/health') {
|
|
829
|
+
const healthy = await browserManager.isHealthy();
|
|
830
|
+
return new Response(JSON.stringify({
|
|
831
|
+
status: healthy ? 'healthy' : 'unhealthy',
|
|
832
|
+
mode: browserManager.getConnectionMode(),
|
|
833
|
+
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
834
|
+
tabs: browserManager.getTabCount(),
|
|
835
|
+
currentUrl: browserManager.getCurrentUrl(),
|
|
836
|
+
// token removed — see .auth.json for extension bootstrap
|
|
837
|
+
chatEnabled: true,
|
|
838
|
+
agent: {
|
|
839
|
+
status: agentStatus,
|
|
840
|
+
runningFor: agentStartTime ? Date.now() - agentStartTime : null,
|
|
841
|
+
currentMessage,
|
|
842
|
+
queueLength: messageQueue.length,
|
|
843
|
+
},
|
|
844
|
+
session: sidebarSession ? { id: sidebarSession.id, name: sidebarSession.name } : null,
|
|
845
|
+
}), {
|
|
846
|
+
status: 200,
|
|
847
|
+
headers: { 'Content-Type': 'application/json' },
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Refs endpoint — auth required, does NOT reset idle timer
|
|
852
|
+
if (url.pathname === '/refs') {
|
|
853
|
+
if (!validateAuth(req)) {
|
|
854
|
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
855
|
+
status: 401,
|
|
856
|
+
headers: { 'Content-Type': 'application/json' },
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
const refs = browserManager.getRefMap();
|
|
860
|
+
return new Response(JSON.stringify({
|
|
861
|
+
refs,
|
|
862
|
+
url: browserManager.getCurrentUrl(),
|
|
863
|
+
mode: browserManager.getConnectionMode(),
|
|
864
|
+
}), {
|
|
865
|
+
status: 200,
|
|
866
|
+
headers: { 'Content-Type': 'application/json' },
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Activity stream — SSE, auth required, does NOT reset idle timer
|
|
871
|
+
if (url.pathname === '/activity/stream') {
|
|
872
|
+
// Inline auth: accept Bearer header OR ?token= query param (EventSource can't send headers)
|
|
873
|
+
const streamToken = url.searchParams.get('token');
|
|
874
|
+
if (!validateAuth(req) && streamToken !== AUTH_TOKEN) {
|
|
875
|
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
876
|
+
status: 401,
|
|
877
|
+
headers: { 'Content-Type': 'application/json' },
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
const afterId = parseInt(url.searchParams.get('after') || '0', 10);
|
|
881
|
+
const encoder = new TextEncoder();
|
|
882
|
+
|
|
883
|
+
const stream = new ReadableStream({
|
|
884
|
+
start(controller) {
|
|
885
|
+
// 1. Gap detection + replay
|
|
886
|
+
const { entries, gap, gapFrom, availableFrom } = getActivityAfter(afterId);
|
|
887
|
+
if (gap) {
|
|
888
|
+
controller.enqueue(encoder.encode(`event: gap\ndata: ${JSON.stringify({ gapFrom, availableFrom })}\n\n`));
|
|
889
|
+
}
|
|
890
|
+
for (const entry of entries) {
|
|
891
|
+
controller.enqueue(encoder.encode(`event: activity\ndata: ${JSON.stringify(entry)}\n\n`));
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// 2. Subscribe for live events
|
|
895
|
+
const unsubscribe = subscribe((entry) => {
|
|
896
|
+
try {
|
|
897
|
+
controller.enqueue(encoder.encode(`event: activity\ndata: ${JSON.stringify(entry)}\n\n`));
|
|
898
|
+
} catch {
|
|
899
|
+
unsubscribe();
|
|
900
|
+
}
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
// 3. Heartbeat every 15s
|
|
904
|
+
const heartbeat = setInterval(() => {
|
|
905
|
+
try {
|
|
906
|
+
controller.enqueue(encoder.encode(`: heartbeat\n\n`));
|
|
907
|
+
} catch {
|
|
908
|
+
clearInterval(heartbeat);
|
|
909
|
+
unsubscribe();
|
|
910
|
+
}
|
|
911
|
+
}, 15000);
|
|
912
|
+
|
|
913
|
+
// 4. Cleanup on disconnect
|
|
914
|
+
req.signal.addEventListener('abort', () => {
|
|
915
|
+
clearInterval(heartbeat);
|
|
916
|
+
unsubscribe();
|
|
917
|
+
try { controller.close(); } catch {}
|
|
918
|
+
});
|
|
919
|
+
},
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
return new Response(stream, {
|
|
923
|
+
headers: {
|
|
924
|
+
'Content-Type': 'text/event-stream',
|
|
925
|
+
'Cache-Control': 'no-cache',
|
|
926
|
+
'Connection': 'keep-alive',
|
|
927
|
+
},
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Activity history — REST, auth required, does NOT reset idle timer
|
|
932
|
+
if (url.pathname === '/activity/history') {
|
|
933
|
+
if (!validateAuth(req)) {
|
|
934
|
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
935
|
+
status: 401,
|
|
936
|
+
headers: { 'Content-Type': 'application/json' },
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
|
940
|
+
const { entries, totalAdded } = getActivityHistory(limit);
|
|
941
|
+
return new Response(JSON.stringify({ entries, totalAdded, subscribers: getSubscriberCount() }), {
|
|
942
|
+
status: 200,
|
|
943
|
+
headers: { 'Content-Type': 'application/json' },
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// ─── Sidebar endpoints (auth required — token from /health) ────
|
|
948
|
+
|
|
949
|
+
// Sidebar routes are always available in headed mode (ungated in v0.12.0)
|
|
950
|
+
|
|
951
|
+
// Sidebar chat history — read from in-memory buffer
|
|
952
|
+
if (url.pathname === '/sidebar-chat') {
|
|
953
|
+
if (!validateAuth(req)) {
|
|
954
|
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
|
955
|
+
}
|
|
956
|
+
const afterId = parseInt(url.searchParams.get('after') || '0', 10);
|
|
957
|
+
const entries = chatBuffer.filter(e => e.id >= afterId);
|
|
958
|
+
return new Response(JSON.stringify({ entries, total: chatNextId }), {
|
|
959
|
+
status: 200,
|
|
960
|
+
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// Sidebar → server: user message → queue or process immediately
|
|
965
|
+
if (url.pathname === '/sidebar-command' && req.method === 'POST') {
|
|
966
|
+
if (!validateAuth(req)) {
|
|
967
|
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
|
968
|
+
}
|
|
969
|
+
const body = await req.json();
|
|
970
|
+
const msg = body.message?.trim();
|
|
971
|
+
if (!msg) {
|
|
972
|
+
return new Response(JSON.stringify({ error: 'Empty message' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
973
|
+
}
|
|
974
|
+
// The Chrome extension sends the active tab's URL — prefer it over
|
|
975
|
+
// Playwright's page.url() which can be stale in headed mode when
|
|
976
|
+
// the user navigates manually.
|
|
977
|
+
const extensionUrl = body.activeTabUrl || null;
|
|
978
|
+
const ts = new Date().toISOString();
|
|
979
|
+
addChatEntry({ ts, role: 'user', message: msg });
|
|
980
|
+
if (sidebarSession) { sidebarSession.lastActiveAt = ts; saveSession(); }
|
|
981
|
+
|
|
982
|
+
if (agentStatus === 'idle') {
|
|
983
|
+
spawnClaude(msg, extensionUrl);
|
|
984
|
+
return new Response(JSON.stringify({ ok: true, processing: true }), {
|
|
985
|
+
status: 200, headers: { 'Content-Type': 'application/json' },
|
|
986
|
+
});
|
|
987
|
+
} else if (messageQueue.length < MAX_QUEUE) {
|
|
988
|
+
messageQueue.push({ message: msg, ts, extensionUrl });
|
|
989
|
+
return new Response(JSON.stringify({ ok: true, queued: true, position: messageQueue.length }), {
|
|
990
|
+
status: 200, headers: { 'Content-Type': 'application/json' },
|
|
991
|
+
});
|
|
992
|
+
} else {
|
|
993
|
+
return new Response(JSON.stringify({ error: 'Queue full (max 5)' }), {
|
|
994
|
+
status: 429, headers: { 'Content-Type': 'application/json' },
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Clear sidebar chat
|
|
1000
|
+
if (url.pathname === '/sidebar-chat/clear' && req.method === 'POST') {
|
|
1001
|
+
if (!validateAuth(req)) {
|
|
1002
|
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
|
1003
|
+
}
|
|
1004
|
+
chatBuffer = [];
|
|
1005
|
+
chatNextId = 0;
|
|
1006
|
+
if (sidebarSession) {
|
|
1007
|
+
try { fs.writeFileSync(path.join(SESSIONS_DIR, sidebarSession.id, 'chat.jsonl'), ''); } catch {}
|
|
1008
|
+
}
|
|
1009
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// Kill hung agent
|
|
1013
|
+
if (url.pathname === '/sidebar-agent/kill' && req.method === 'POST') {
|
|
1014
|
+
if (!validateAuth(req)) {
|
|
1015
|
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
|
1016
|
+
}
|
|
1017
|
+
killAgent();
|
|
1018
|
+
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_error', error: 'Killed by user' });
|
|
1019
|
+
// Process next in queue
|
|
1020
|
+
if (messageQueue.length > 0) {
|
|
1021
|
+
const next = messageQueue.shift()!;
|
|
1022
|
+
spawnClaude(next.message, next.extensionUrl);
|
|
1023
|
+
}
|
|
1024
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// Stop agent (user-initiated) — queued messages remain for dismissal
|
|
1028
|
+
if (url.pathname === '/sidebar-agent/stop' && req.method === 'POST') {
|
|
1029
|
+
if (!validateAuth(req)) {
|
|
1030
|
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
|
1031
|
+
}
|
|
1032
|
+
killAgent();
|
|
1033
|
+
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_error', error: 'Stopped by user' });
|
|
1034
|
+
return new Response(JSON.stringify({ ok: true, queuedMessages: messageQueue.length }), {
|
|
1035
|
+
status: 200, headers: { 'Content-Type': 'application/json' },
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Dismiss a queued message by index
|
|
1040
|
+
if (url.pathname === '/sidebar-queue/dismiss' && req.method === 'POST') {
|
|
1041
|
+
if (!validateAuth(req)) {
|
|
1042
|
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
|
1043
|
+
}
|
|
1044
|
+
const body = await req.json();
|
|
1045
|
+
const idx = body.index;
|
|
1046
|
+
if (typeof idx === 'number' && idx >= 0 && idx < messageQueue.length) {
|
|
1047
|
+
messageQueue.splice(idx, 1);
|
|
1048
|
+
}
|
|
1049
|
+
return new Response(JSON.stringify({ ok: true, queueLength: messageQueue.length }), {
|
|
1050
|
+
status: 200, headers: { 'Content-Type': 'application/json' },
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Session info
|
|
1055
|
+
if (url.pathname === '/sidebar-session') {
|
|
1056
|
+
if (!validateAuth(req)) {
|
|
1057
|
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
|
1058
|
+
}
|
|
1059
|
+
return new Response(JSON.stringify({
|
|
1060
|
+
session: sidebarSession,
|
|
1061
|
+
agent: { status: agentStatus, runningFor: agentStartTime ? Date.now() - agentStartTime : null, currentMessage, queueLength: messageQueue.length, queue: messageQueue },
|
|
1062
|
+
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Create new session
|
|
1066
|
+
if (url.pathname === '/sidebar-session/new' && req.method === 'POST') {
|
|
1067
|
+
if (!validateAuth(req)) {
|
|
1068
|
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
|
1069
|
+
}
|
|
1070
|
+
killAgent();
|
|
1071
|
+
messageQueue = [];
|
|
1072
|
+
// Clean up old session's worktree before creating new one
|
|
1073
|
+
if (sidebarSession?.worktreePath) removeWorktree(sidebarSession.worktreePath);
|
|
1074
|
+
sidebarSession = createSession();
|
|
1075
|
+
return new Response(JSON.stringify({ ok: true, session: sidebarSession }), {
|
|
1076
|
+
status: 200, headers: { 'Content-Type': 'application/json' },
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// List all sessions
|
|
1081
|
+
if (url.pathname === '/sidebar-session/list') {
|
|
1082
|
+
if (!validateAuth(req)) {
|
|
1083
|
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
|
1084
|
+
}
|
|
1085
|
+
return new Response(JSON.stringify({ sessions: listSessions(), activeId: sidebarSession?.id }), {
|
|
1086
|
+
status: 200, headers: { 'Content-Type': 'application/json' },
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// Agent event relay — sidebar-agent.ts POSTs events here
|
|
1091
|
+
if (url.pathname === '/sidebar-agent/event' && req.method === 'POST') {
|
|
1092
|
+
if (!validateAuth(req)) {
|
|
1093
|
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
|
1094
|
+
}
|
|
1095
|
+
const body = await req.json();
|
|
1096
|
+
processAgentEvent(body);
|
|
1097
|
+
// Handle agent lifecycle events
|
|
1098
|
+
if (body.type === 'agent_done' || body.type === 'agent_error') {
|
|
1099
|
+
agentProcess = null;
|
|
1100
|
+
agentStartTime = null;
|
|
1101
|
+
currentMessage = null;
|
|
1102
|
+
if (body.type === 'agent_done') {
|
|
1103
|
+
addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_done' });
|
|
1104
|
+
}
|
|
1105
|
+
// Process next queued message
|
|
1106
|
+
if (messageQueue.length > 0) {
|
|
1107
|
+
const next = messageQueue.shift()!;
|
|
1108
|
+
spawnClaude(next.message, next.extensionUrl);
|
|
1109
|
+
} else {
|
|
1110
|
+
agentStatus = 'idle';
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
// Capture claude session ID for --resume
|
|
1114
|
+
if (body.claudeSessionId && sidebarSession && !sidebarSession.claudeSessionId) {
|
|
1115
|
+
sidebarSession.claudeSessionId = body.claudeSessionId;
|
|
1116
|
+
saveSession();
|
|
1117
|
+
}
|
|
1118
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// ─── Auth-required endpoints ──────────────────────────────────
|
|
1122
|
+
|
|
1123
|
+
if (!validateAuth(req)) {
|
|
1124
|
+
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
|
1125
|
+
status: 401,
|
|
1126
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
if (url.pathname === '/command' && req.method === 'POST') {
|
|
1131
|
+
resetIdleTimer(); // Only commands reset idle timer
|
|
1132
|
+
const body = await req.json();
|
|
1133
|
+
return handleCommand(body);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
return new Response('Not found', { status: 404 });
|
|
1137
|
+
},
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
// Write state file (atomic: write .tmp then rename)
|
|
1141
|
+
const state: Record<string, unknown> = {
|
|
1142
|
+
pid: process.pid,
|
|
1143
|
+
port,
|
|
1144
|
+
token: AUTH_TOKEN,
|
|
1145
|
+
startedAt: new Date().toISOString(),
|
|
1146
|
+
serverPath: path.resolve(import.meta.dir, 'server.ts'),
|
|
1147
|
+
binaryVersion: readVersionHash() || undefined,
|
|
1148
|
+
mode: browserManager.getConnectionMode(),
|
|
1149
|
+
};
|
|
1150
|
+
const tmpFile = config.stateFile + '.tmp';
|
|
1151
|
+
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
1152
|
+
fs.renameSync(tmpFile, config.stateFile);
|
|
1153
|
+
|
|
1154
|
+
browserManager.serverPort = port;
|
|
1155
|
+
|
|
1156
|
+
// Clean up stale state files (older than 7 days)
|
|
1157
|
+
try {
|
|
1158
|
+
const stateDir = path.join(config.stateDir, 'browse-states');
|
|
1159
|
+
if (fs.existsSync(stateDir)) {
|
|
1160
|
+
const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
|
|
1161
|
+
for (const file of fs.readdirSync(stateDir)) {
|
|
1162
|
+
const filePath = path.join(stateDir, file);
|
|
1163
|
+
const stat = fs.statSync(filePath);
|
|
1164
|
+
if (Date.now() - stat.mtimeMs > SEVEN_DAYS) {
|
|
1165
|
+
fs.unlinkSync(filePath);
|
|
1166
|
+
console.log(`[browse] Deleted stale state file: ${file}`);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
} catch {}
|
|
1171
|
+
|
|
1172
|
+
console.log(`[browse] Server running on http://127.0.0.1:${port} (PID: ${process.pid})`);
|
|
1173
|
+
console.log(`[browse] State file: ${config.stateFile}`);
|
|
1174
|
+
console.log(`[browse] Idle timeout: ${IDLE_TIMEOUT_MS / 1000}s`);
|
|
1175
|
+
|
|
1176
|
+
// Initialize sidebar session (load existing or create new)
|
|
1177
|
+
initSidebarSession();
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
start().catch((err) => {
|
|
1181
|
+
console.error(`[browse] Failed to start: ${err.message}`);
|
|
1182
|
+
// Write error to disk for the CLI to read — on Windows, the CLI can't capture
|
|
1183
|
+
// stderr because the server is launched with detached: true, stdio: 'ignore'.
|
|
1184
|
+
try {
|
|
1185
|
+
const errorLogPath = path.join(config.stateDir, 'browse-startup-error.log');
|
|
1186
|
+
fs.mkdirSync(config.stateDir, { recursive: true });
|
|
1187
|
+
fs.writeFileSync(errorLogPath, `${new Date().toISOString()} ${err.message}\n${err.stack || ''}\n`);
|
|
1188
|
+
} catch {
|
|
1189
|
+
// stateDir may not exist — nothing more we can do
|
|
1190
|
+
}
|
|
1191
|
+
process.exit(1);
|
|
1192
|
+
});
|