opengstack 0.13.10 → 0.14.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.
Files changed (151) hide show
  1. package/{skills/land-and-deploy/SKILL.md → commands/autoplan.md} +0 -16
  2. package/{skills/benchmark/SKILL.md → commands/benchmark.md} +0 -17
  3. package/{skills/browse/SKILL.md → commands/browse.md} +0 -17
  4. package/{skills/ship/SKILL.md → commands/canary.md} +0 -18
  5. package/{skills/careful/SKILL.md → commands/careful.md} +0 -20
  6. package/{skills/canary/SKILL.md → commands/codex.md} +0 -17
  7. package/{skills/connect-chrome/SKILL.md → commands/connect-chrome.md} +0 -15
  8. package/commands/cso.md +72 -0
  9. package/commands/design-consultation.md +72 -0
  10. package/commands/design-review.md +72 -0
  11. package/commands/design-shotgun.md +72 -0
  12. package/commands/document-release.md +72 -0
  13. package/{skills/freeze/SKILL.md → commands/freeze.md} +0 -26
  14. package/{skills/gstack-upgrade/SKILL.md → commands/gstack-upgrade.md} +0 -14
  15. package/{skills/guard/SKILL.md → commands/guard.md} +0 -31
  16. package/commands/investigate.md +72 -0
  17. package/commands/land-and-deploy.md +72 -0
  18. package/commands/office-hours.md +72 -0
  19. package/commands/plan-ceo-review.md +72 -0
  20. package/commands/plan-design-review.md +72 -0
  21. package/commands/plan-eng-review.md +72 -0
  22. package/commands/qa-only.md +72 -0
  23. package/commands/qa.md +72 -0
  24. package/commands/retro.md +72 -0
  25. package/commands/review.md +72 -0
  26. package/{skills/setup-browser-cookies/SKILL.md → commands/setup-browser-cookies.md} +0 -14
  27. package/commands/setup-deploy.md +72 -0
  28. package/commands/ship.md +72 -0
  29. package/{skills/unfreeze/SKILL.md → commands/unfreeze.md} +0 -12
  30. package/package.json +4 -4
  31. package/scripts/install-commands.js +45 -0
  32. package/skills/autoplan/SKILL.md +0 -96
  33. package/skills/autoplan/SKILL.md.tmpl +0 -694
  34. package/skills/benchmark/SKILL.md.tmpl +0 -222
  35. package/skills/browse/SKILL.md.tmpl +0 -131
  36. package/skills/browse/bin/find-browse +0 -21
  37. package/skills/browse/bin/remote-slug +0 -14
  38. package/skills/browse/scripts/build-node-server.sh +0 -48
  39. package/skills/browse/src/activity.ts +0 -208
  40. package/skills/browse/src/browser-manager.ts +0 -959
  41. package/skills/browse/src/buffers.ts +0 -137
  42. package/skills/browse/src/bun-polyfill.cjs +0 -109
  43. package/skills/browse/src/cli.ts +0 -678
  44. package/skills/browse/src/commands.ts +0 -128
  45. package/skills/browse/src/config.ts +0 -150
  46. package/skills/browse/src/cookie-import-browser.ts +0 -625
  47. package/skills/browse/src/cookie-picker-routes.ts +0 -230
  48. package/skills/browse/src/cookie-picker-ui.ts +0 -688
  49. package/skills/browse/src/find-browse.ts +0 -61
  50. package/skills/browse/src/meta-commands.ts +0 -550
  51. package/skills/browse/src/platform.ts +0 -17
  52. package/skills/browse/src/read-commands.ts +0 -358
  53. package/skills/browse/src/server.ts +0 -1192
  54. package/skills/browse/src/sidebar-agent.ts +0 -280
  55. package/skills/browse/src/sidebar-utils.ts +0 -21
  56. package/skills/browse/src/snapshot.ts +0 -407
  57. package/skills/browse/src/url-validation.ts +0 -95
  58. package/skills/browse/src/write-commands.ts +0 -364
  59. package/skills/browse/test/activity.test.ts +0 -120
  60. package/skills/browse/test/adversarial-security.test.ts +0 -32
  61. package/skills/browse/test/browser-manager-unit.test.ts +0 -17
  62. package/skills/browse/test/bun-polyfill.test.ts +0 -72
  63. package/skills/browse/test/commands.test.ts +0 -2075
  64. package/skills/browse/test/compare-board.test.ts +0 -342
  65. package/skills/browse/test/config.test.ts +0 -316
  66. package/skills/browse/test/cookie-import-browser.test.ts +0 -519
  67. package/skills/browse/test/cookie-picker-routes.test.ts +0 -260
  68. package/skills/browse/test/file-drop.test.ts +0 -271
  69. package/skills/browse/test/find-browse.test.ts +0 -50
  70. package/skills/browse/test/findport.test.ts +0 -191
  71. package/skills/browse/test/fixtures/basic.html +0 -33
  72. package/skills/browse/test/fixtures/cursor-interactive.html +0 -22
  73. package/skills/browse/test/fixtures/dialog.html +0 -15
  74. package/skills/browse/test/fixtures/empty.html +0 -2
  75. package/skills/browse/test/fixtures/forms.html +0 -55
  76. package/skills/browse/test/fixtures/iframe.html +0 -30
  77. package/skills/browse/test/fixtures/network-idle.html +0 -30
  78. package/skills/browse/test/fixtures/qa-eval-checkout.html +0 -108
  79. package/skills/browse/test/fixtures/qa-eval-spa.html +0 -98
  80. package/skills/browse/test/fixtures/qa-eval.html +0 -51
  81. package/skills/browse/test/fixtures/responsive.html +0 -49
  82. package/skills/browse/test/fixtures/snapshot.html +0 -55
  83. package/skills/browse/test/fixtures/spa.html +0 -24
  84. package/skills/browse/test/fixtures/states.html +0 -17
  85. package/skills/browse/test/fixtures/upload.html +0 -25
  86. package/skills/browse/test/gstack-config.test.ts +0 -138
  87. package/skills/browse/test/gstack-update-check.test.ts +0 -514
  88. package/skills/browse/test/handoff.test.ts +0 -235
  89. package/skills/browse/test/path-validation.test.ts +0 -91
  90. package/skills/browse/test/platform.test.ts +0 -37
  91. package/skills/browse/test/server-auth.test.ts +0 -65
  92. package/skills/browse/test/sidebar-agent-roundtrip.test.ts +0 -226
  93. package/skills/browse/test/sidebar-agent.test.ts +0 -199
  94. package/skills/browse/test/sidebar-integration.test.ts +0 -320
  95. package/skills/browse/test/sidebar-unit.test.ts +0 -96
  96. package/skills/browse/test/snapshot.test.ts +0 -467
  97. package/skills/browse/test/state-ttl.test.ts +0 -35
  98. package/skills/browse/test/test-server.ts +0 -57
  99. package/skills/browse/test/url-validation.test.ts +0 -72
  100. package/skills/browse/test/watch.test.ts +0 -129
  101. package/skills/canary/SKILL.md.tmpl +0 -212
  102. package/skills/careful/SKILL.md.tmpl +0 -56
  103. package/skills/careful/bin/check-careful.sh +0 -112
  104. package/skills/codex/SKILL.md +0 -90
  105. package/skills/codex/SKILL.md.tmpl +0 -417
  106. package/skills/connect-chrome/SKILL.md.tmpl +0 -195
  107. package/skills/cso/ACKNOWLEDGEMENTS.md +0 -14
  108. package/skills/cso/SKILL.md +0 -93
  109. package/skills/cso/SKILL.md.tmpl +0 -606
  110. package/skills/design-consultation/SKILL.md +0 -94
  111. package/skills/design-consultation/SKILL.md.tmpl +0 -415
  112. package/skills/design-review/SKILL.md +0 -94
  113. package/skills/design-review/SKILL.md.tmpl +0 -290
  114. package/skills/design-shotgun/SKILL.md +0 -91
  115. package/skills/design-shotgun/SKILL.md.tmpl +0 -285
  116. package/skills/document-release/SKILL.md +0 -91
  117. package/skills/document-release/SKILL.md.tmpl +0 -359
  118. package/skills/freeze/SKILL.md.tmpl +0 -77
  119. package/skills/freeze/bin/check-freeze.sh +0 -79
  120. package/skills/gstack-upgrade/SKILL.md.tmpl +0 -222
  121. package/skills/guard/SKILL.md.tmpl +0 -77
  122. package/skills/investigate/SKILL.md +0 -105
  123. package/skills/investigate/SKILL.md.tmpl +0 -194
  124. package/skills/land-and-deploy/SKILL.md.tmpl +0 -881
  125. package/skills/office-hours/SKILL.md +0 -96
  126. package/skills/office-hours/SKILL.md.tmpl +0 -645
  127. package/skills/plan-ceo-review/SKILL.md +0 -94
  128. package/skills/plan-ceo-review/SKILL.md.tmpl +0 -811
  129. package/skills/plan-design-review/SKILL.md +0 -92
  130. package/skills/plan-design-review/SKILL.md.tmpl +0 -446
  131. package/skills/plan-eng-review/SKILL.md +0 -93
  132. package/skills/plan-eng-review/SKILL.md.tmpl +0 -303
  133. package/skills/qa/SKILL.md +0 -95
  134. package/skills/qa/SKILL.md.tmpl +0 -316
  135. package/skills/qa/references/issue-taxonomy.md +0 -85
  136. package/skills/qa/templates/qa-report-template.md +0 -126
  137. package/skills/qa-only/SKILL.md +0 -89
  138. package/skills/qa-only/SKILL.md.tmpl +0 -101
  139. package/skills/retro/SKILL.md +0 -89
  140. package/skills/retro/SKILL.md.tmpl +0 -820
  141. package/skills/review/SKILL.md +0 -92
  142. package/skills/review/SKILL.md.tmpl +0 -281
  143. package/skills/review/TODOS-format.md +0 -62
  144. package/skills/review/checklist.md +0 -220
  145. package/skills/review/design-checklist.md +0 -132
  146. package/skills/review/greptile-triage.md +0 -220
  147. package/skills/setup-browser-cookies/SKILL.md.tmpl +0 -81
  148. package/skills/setup-deploy/SKILL.md +0 -92
  149. package/skills/setup-deploy/SKILL.md.tmpl +0 -215
  150. package/skills/ship/SKILL.md.tmpl +0 -636
  151. package/skills/unfreeze/SKILL.md.tmpl +0 -36
@@ -1,1192 +0,0 @@
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
- });