osborn 0.8.34 → 0.9.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.
@@ -11,6 +11,8 @@ RUN apt-get update -qq && \
11
11
  ca-certificates \
12
12
  curl \
13
13
  git \
14
+ make \
15
+ g++ \
14
16
  python-is-python3 && \
15
17
  rm -rf /var/lib/apt/lists/*
16
18
 
package/dist/index.js CHANGED
@@ -10,8 +10,16 @@ initializeLogger({ pretty: true, level: 'info' });
10
10
  import { setMaxListeners } from 'node:events';
11
11
  setMaxListeners(50);
12
12
  import { createServer } from 'http';
13
- import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
14
- import { join } from 'node:path';
13
+ import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, mkdtempSync, cpSync } from 'node:fs';
14
+ import { dirname, join } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { spawn } from 'node:child_process';
17
+ import { homedir } from 'node:os';
18
+ // Resolve __dirname for this ESM module so we can find sibling files (e.g.
19
+ // meeting-output.html) relative to the compiled JS location, NOT process.cwd().
20
+ // In production cwd is the user's workspace; the static file lives next to dist/index.js.
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = dirname(__filename);
15
23
  import { createPatch } from 'diff';
16
24
  import { loadConfig, getMcpServers, getEnabledMcpServerNames, getVoiceMode, getRealtimeConfig, getDirectConfig, listAllClaudeSessions, getMostRecentSessionId, sessionExists, getSessionSummary, getConversationHistory, ensureSessionWorkspace, getSessionWorkspace, getMcpServerStatusList, buildMcpServersForKeys, listWorkspaceArtifacts } from './config.js';
17
25
  import { createSTT, createTTS, createRealtimeModelFromConfig, DIRECT_MODE_STT, DIRECT_MODE_TTS } from './voice-io.js';
@@ -148,6 +156,7 @@ function startApiServer(workingDir, port) {
148
156
  return;
149
157
  }
150
158
  const url = new URL(req.url || '/', `http://localhost:${port}`);
159
+ const syncToken = process.env.OSBORN_SYNC_TOKEN;
151
160
  if (req.method === 'GET' && url.pathname === '/sessions') {
152
161
  try {
153
162
  const limit = parseInt(url.searchParams.get('limit') || '100', 10);
@@ -200,15 +209,35 @@ function startApiServer(workingDir, port) {
200
209
  });
201
210
  return;
202
211
  }
203
- // GET /meeting-output — Output Media webpage for Recall.ai bot audio
212
+ // GET /meeting-output — Output Media webpage for Recall.ai bot audio.
213
+ //
214
+ // The file lives next to this compiled JS (copied by the build script from
215
+ // src/ to dist/). Resolve via __dirname rather than process.cwd() — in
216
+ // production cwd is the user's workspace, NOT the osborn package directory.
204
217
  if (req.method === 'GET' && url.pathname === '/meeting-output') {
205
- const htmlPath = join(process.cwd(), 'src', 'meeting-output.html');
206
- try {
207
- const html = readFileSync(htmlPath, 'utf-8');
218
+ // Try the package-relative path first (post-build location), then fall
219
+ // back to source path for `tsx src/index.ts` dev runs.
220
+ const candidates = [
221
+ join(__dirname, 'meeting-output.html'), // dist/ (production)
222
+ join(__dirname, '..', 'src', 'meeting-output.html'), // dev: dist/ → src/
223
+ join(__dirname, '..', 'meeting-output.html'), // tsx run from src/
224
+ ];
225
+ let html = null;
226
+ let foundPath = null;
227
+ for (const p of candidates) {
228
+ try {
229
+ html = readFileSync(p, 'utf-8');
230
+ foundPath = p;
231
+ break;
232
+ }
233
+ catch { }
234
+ }
235
+ if (html) {
208
236
  res.writeHead(200, { 'Content-Type': 'text/html' });
209
237
  res.end(html);
210
238
  }
211
- catch {
239
+ else {
240
+ console.warn(`[meeting-output] not found in any of: ${candidates.join(', ')}`);
212
241
  res.writeHead(404, { 'Content-Type': 'text/plain' });
213
242
  res.end('meeting-output.html not found');
214
243
  }
@@ -268,6 +297,136 @@ function startApiServer(workingDir, port) {
268
297
  console.log('[events] SSE client connected');
269
298
  return;
270
299
  }
300
+ // GET /sessions/export — stream a gzipped tar of ~/.claude/projects/ to the client
301
+ // Optional ?workDir= query param: if present, export only that project's slug folder.
302
+ if (req.method === 'GET' && url.pathname === '/sessions/export') {
303
+ if (syncToken) {
304
+ const authHeader = req.headers['authorization'] ?? '';
305
+ if (authHeader !== `Bearer ${syncToken}`) {
306
+ res.writeHead(401, { 'Content-Type': 'application/json' });
307
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
308
+ return;
309
+ }
310
+ }
311
+ const projectsDir = join(homedir(), '.claude', 'projects');
312
+ const workDir = url.searchParams.get('workDir');
313
+ if (workDir) {
314
+ const slug = workDir.replace(/\//g, '-');
315
+ const slugDir = join(projectsDir, slug);
316
+ if (!existsSync(slugDir)) {
317
+ res.writeHead(404, { 'Content-Type': 'application/json' });
318
+ res.end(JSON.stringify({ error: 'Project not found', slug }));
319
+ return;
320
+ }
321
+ res.writeHead(200, {
322
+ 'Content-Type': 'application/gzip',
323
+ 'Content-Disposition': `attachment; filename="claude-sessions-${slug}.tar.gz"`,
324
+ 'Access-Control-Allow-Origin': '*',
325
+ });
326
+ const tar = spawn('tar', ['-czf', '-', '-C', projectsDir, slug]);
327
+ tar.stdout.pipe(res);
328
+ tar.stderr.on('data', (d) => console.error('[export]', d.toString()));
329
+ tar.on('close', (code) => { if (code !== 0)
330
+ res.destroy(); });
331
+ return;
332
+ }
333
+ if (!existsSync(projectsDir)) {
334
+ res.writeHead(404, { 'Content-Type': 'application/json' });
335
+ res.end(JSON.stringify({ error: 'No sessions found' }));
336
+ return;
337
+ }
338
+ res.writeHead(200, {
339
+ 'Content-Type': 'application/gzip',
340
+ 'Content-Disposition': 'attachment; filename="claude-sessions.tar.gz"',
341
+ 'Access-Control-Allow-Origin': '*',
342
+ });
343
+ // Stream tar output directly to response
344
+ const tar = spawn('tar', ['-czf', '-', '-C', join(homedir(), '.claude'), 'projects']);
345
+ tar.stdout.pipe(res);
346
+ tar.stderr.on('data', (d) => console.error('[export]', d.toString()));
347
+ tar.on('close', (code) => { if (code !== 0)
348
+ res.destroy(); });
349
+ return;
350
+ }
351
+ // POST /sessions/import — accept a gzipped tar and extract into ~/.claude/projects/
352
+ if (req.method === 'POST' && url.pathname === '/sessions/import') {
353
+ if (syncToken) {
354
+ const authHeader = req.headers['authorization'] ?? '';
355
+ if (authHeader !== `Bearer ${syncToken}`) {
356
+ res.writeHead(401, { 'Content-Type': 'application/json' });
357
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
358
+ return;
359
+ }
360
+ }
361
+ const targetWorkDir = url.searchParams.get('targetWorkDir');
362
+ const chunks = [];
363
+ req.on('data', (chunk) => chunks.push(chunk));
364
+ req.on('end', () => {
365
+ const body = Buffer.concat(chunks);
366
+ const tmpDir = mkdtempSync('/tmp/osborn-import-');
367
+ try {
368
+ // Extract archive into temp dir
369
+ const tarProc = spawn('tar', ['-xzf', '-', '-C', tmpDir]);
370
+ tarProc.stdin.write(body);
371
+ tarProc.stdin.end();
372
+ tarProc.stderr.on('data', (d) => console.error('[import]', d.toString()));
373
+ tarProc.on('close', (code) => {
374
+ if (code !== 0) {
375
+ res.writeHead(500, { 'Content-Type': 'application/json' });
376
+ res.end(JSON.stringify({ error: 'tar extraction failed', code }));
377
+ return;
378
+ }
379
+ try {
380
+ const projectsDir = join(homedir(), '.claude', 'projects');
381
+ mkdirSync(projectsDir, { recursive: true });
382
+ // The archive should contain a 'projects' subdirectory
383
+ const extractedProjects = join(tmpDir, 'projects');
384
+ const sourceDir = existsSync(extractedProjects) ? extractedProjects : tmpDir;
385
+ // Optionally remap slug: if targetWorkDir is provided, find slug(s)
386
+ // that don't match the target and rename them
387
+ let remapped = {};
388
+ if (targetWorkDir) {
389
+ const targetSlug = targetWorkDir.replace(/\//g, '-');
390
+ const sourceSlugs = readdirSync(sourceDir);
391
+ for (const slug of sourceSlugs) {
392
+ if (slug !== targetSlug && !slug.startsWith('.')) {
393
+ const newSlug = targetSlug;
394
+ remapped[slug] = newSlug;
395
+ }
396
+ }
397
+ }
398
+ // Copy subdirectories into ~/.claude/projects/, merging without overwriting
399
+ let filesWritten = 0;
400
+ const slugsInSource = readdirSync(sourceDir);
401
+ for (const slug of slugsInSource) {
402
+ const effectiveSlug = remapped[slug] ?? slug;
403
+ const destSlug = join(projectsDir, effectiveSlug);
404
+ mkdirSync(destSlug, { recursive: true });
405
+ cpSync(join(sourceDir, slug), destSlug, {
406
+ recursive: true,
407
+ force: false, // don't overwrite existing files
408
+ errorOnExist: false,
409
+ });
410
+ filesWritten++;
411
+ }
412
+ res.writeHead(200, { 'Content-Type': 'application/json' });
413
+ res.end(JSON.stringify({ ok: true, filesWritten, remapped }));
414
+ }
415
+ catch (err) {
416
+ console.error('[import] merge error:', err);
417
+ res.writeHead(500, { 'Content-Type': 'application/json' });
418
+ res.end(JSON.stringify({ error: 'Failed to merge sessions', detail: String(err) }));
419
+ }
420
+ });
421
+ }
422
+ catch (err) {
423
+ console.error('[import] spawn error:', err);
424
+ res.writeHead(500, { 'Content-Type': 'application/json' });
425
+ res.end(JSON.stringify({ error: 'Failed to start extraction', detail: String(err) }));
426
+ }
427
+ });
428
+ return;
429
+ }
271
430
  res.writeHead(404, { 'Content-Type': 'application/json' });
272
431
  res.end(JSON.stringify({ error: 'Not found' }));
273
432
  });
@@ -0,0 +1,32 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head><title>Osborn Meeting Output</title></head>
4
+ <body>
5
+ <script>
6
+ const botId = new URLSearchParams(window.location.search).get('bot_id') || 'unknown'
7
+
8
+ function connect() {
9
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
10
+ const ws = new WebSocket(`${protocol}//${window.location.host}/meeting-audio?bot_id=${botId}`)
11
+
12
+ ws.onmessage = async (event) => {
13
+ try {
14
+ const audioCtx = new (window.AudioContext || window.webkitAudioContext)()
15
+ const arrayBuffer = await event.data.arrayBuffer()
16
+ const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer)
17
+ const source = audioCtx.createBufferSource()
18
+ source.buffer = audioBuffer
19
+ source.connect(audioCtx.destination)
20
+ source.start()
21
+ } catch (e) {
22
+ console.error('Audio playback error:', e)
23
+ }
24
+ }
25
+
26
+ ws.onclose = () => setTimeout(connect, 1000)
27
+ }
28
+
29
+ connect()
30
+ </script>
31
+ </body>
32
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.8.34",
3
+ "version": "0.9.0",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,7 @@
11
11
  "dev:logged": "tsx scripts/dev-logged.ts",
12
12
  "review": "tsx scripts/review.ts",
13
13
  "start": "tsx src/index.ts",
14
- "build": "tsc && rm -rf dist/prompts && cp -r src/prompts dist/prompts",
14
+ "build": "tsc && rm -rf dist/prompts && cp -r src/prompts dist/prompts && cp src/meeting-output.html dist/",
15
15
  "room": "tsx src/index.ts --room",
16
16
  "prepublishOnly": "npm run build"
17
17
  },
@@ -1,9 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(ps:*)",
5
- "Bash(osascript:*)",
6
- "Bash(curl -s http://localhost:3000)"
7
- ]
8
- }
9
- }
@@ -1,114 +0,0 @@
1
- # Skill: Browser Apply — Step-by-Step Workday Application
2
-
3
- Automate Workday job applications interactively, one step at a time. Each step takes a screenshot, confirms what's on screen, fills the current page, and waits before proceeding.
4
-
5
- **This skill uses the Playwright MCP tools** (`mcp__playwright__browser_*`) for direct browser control — no scripts needed.
6
-
7
- ## When to Use
8
- - Any Workday ATS application (`*.wd1.myworkdayjobs.com`)
9
- - Any multi-step JS-heavy job application form
10
- - When you want visible, confirmable progress at each step
11
-
12
- ## Key Principle: Step-by-Step, Not One Big Script
13
-
14
- Do NOT write a monolithic automation script. Instead:
15
- 1. Navigate to the URL
16
- 2. Take a screenshot — confirm what's on screen
17
- 3. Fill only the current step's fields
18
- 4. Take another screenshot — confirm fields filled correctly
19
- 5. Ask the user "Ready for next step?" before clicking Next
20
- 6. Click Next, wait for page load, screenshot again
21
- 7. Repeat for each step
22
-
23
- This approach catches rendering issues, unexpected fields, and errors before they cascade.
24
-
25
- ## Step-by-Step Execution Pattern
26
-
27
- ### Step 0 — Open the browser
28
- Use: `mcp__playwright__browser_navigate` with the applyManually URL
29
-
30
- Then: `mcp__playwright__browser_take_screenshot` — show the user what loaded
31
-
32
- ### Step 1 — Create Account / Sign In
33
- Take a snapshot with `mcp__playwright__browser_snapshot` to see element refs.
34
- Fill fields using `mcp__playwright__browser_fill_form` or individual `mcp__playwright__browser_type` calls.
35
- Screenshot to confirm. Then ask user before clicking Create Account / Sign In.
36
-
37
- ### Step 2 — Start Application
38
- If "Start Your Application" screen appears with Apply Manually button:
39
- Screenshot it. Click "Apply Manually" using `mcp__playwright__browser_click`.
40
- Screenshot after.
41
-
42
- ### Step 3 — My Information
43
- Snapshot → fill each field → screenshot → ask user before clicking Next.
44
-
45
- Fields to fill:
46
- - First Name, Last Name, Phone
47
- - Address, City, State (dropdown), Zip
48
- - Work authorization: Yes
49
- - Sponsorship: No
50
-
51
- ### Step 4 — My Experience
52
- Snapshot → click Add for each job entry → fill title/company/dates/description → save each → screenshot.
53
- Then add education entries.
54
- Ask user before clicking Next.
55
-
56
- ### Step 5 — Application Questions
57
- Snapshot to see all questions. Fill each one. **Always confirm salary expectation with user before filling** — never guess. Screenshot. Ask before Next.
58
-
59
- ### Step 6 — Voluntary Disclosures
60
- Select "I do not wish to answer" / "Prefer not to disclose" for all. Screenshot. Ask before Next.
61
-
62
- ### Step 7 — Self Identify
63
- Fill name and date. Select disability option. Screenshot. Ask before Next.
64
-
65
- ### Step 8 — Review
66
- Screenshot the full review page. Confirm with user before clicking Submit.
67
-
68
- ### Step 9 — Confirm submission
69
- Screenshot the confirmation dialog. Save it.
70
-
71
- ## Candidate Data (Osborn Ojure)
72
-
73
- - Email: osbornojure@gmail.com
74
- - Password: Workday2026!
75
- - First: Osborn, Last: Ojure
76
- - Phone: 3127185561
77
- - Address: 1234 N Michigan Ave, Chicago, IL 60601
78
-
79
- Jobs:
80
- 1. Meta API Consultant at Prehype / Audos — April 2024 to Present
81
- 2. Full Stack Developer, Freelance — January 2016 to Present
82
-
83
- Education:
84
- 1. A.S. Information Systems
85
- 2. B.S. Psychology
86
-
87
- ## Workday data-automation-id Selector Reference
88
-
89
- | Field | Selector |
90
- |---|---|
91
- | Email | `input[type="email"]` |
92
- | Password | `input[type="password"]` |
93
- | First name | `[data-automation-id="legalNameSection_firstName"]` |
94
- | Last name | `[data-automation-id="legalNameSection_lastName"]` |
95
- | Phone | `[data-automation-id="phone-number"]` |
96
- | Address | `[data-automation-id="addressSection_addressLine1"]` |
97
- | City | `[data-automation-id="addressSection_city"]` |
98
- | Zip | `[data-automation-id="addressSection_postalCode"]` |
99
- | Job title | `[data-automation-id="jobTitle"]` |
100
- | Company | `[data-automation-id="company"]` |
101
- | Description | `[data-automation-id="description"]` |
102
- | Next button | `[data-automation-id="bottom-navigation-next-btn"]` |
103
- | Create Account | `[data-automation-id="click_filter"][aria-label="Create Account"]` |
104
-
105
- ## Critical Rules
106
- - headless: false always (Workday renders blank in headless)
107
- - Confirm salary with user before every submission — never auto-fill
108
- - After "You already applied to this job" error — that confirms a previous submission worked
109
- - Use `{ force: true }` on Workday buttons — overlay click filters block normal clicks
110
- - Always wait for networkidle or waitForSelector after navigation before interacting
111
-
112
- ## Playwright Install Location
113
- Run scripts from: `/Users/newupgrade/Desktop/Developer/osborn/frontend`
114
- (playwright is in `node_modules` there)
@@ -1,29 +0,0 @@
1
- # Skill: Markdown to PDF
2
-
3
- Export Markdown documents as formatted PDF files.
4
-
5
- ## When to use
6
- When the user wants to create a PDF from a Markdown file, spec, or research findings.
7
-
8
- ## How to execute
9
-
10
- Option 1 — Using md-to-pdf (best quality):
11
- ```bash
12
- npx --yes md-to-pdf "<MARKDOWN_PATH>"
13
- ```
14
- This creates a PDF alongside the source file with the same name.
15
-
16
- Option 2 — Using pandoc (if available):
17
- ```bash
18
- pandoc "<MARKDOWN_PATH>" -o "<OUTPUT_PATH>.pdf" --pdf-engine=wkhtmltopdf
19
- ```
20
-
21
- Option 3 — Using markdown-pdf:
22
- ```bash
23
- npx --yes markdown-pdf "<MARKDOWN_PATH>" -o "<OUTPUT_PATH>.pdf"
24
- ```
25
-
26
- ## Output
27
- - Save the PDF to the session workspace (e.g., `library/{name}.pdf`)
28
- - Confirm the output path and file size to the user
29
- - If the source is spec.md, name the output `spec-export.pdf`
@@ -1,28 +0,0 @@
1
- # Skill: PDF to Markdown
2
-
3
- Convert PDF documents to readable Markdown text.
4
-
5
- ## When to use
6
- When the user provides a PDF file path and wants to read, search, or work with its contents.
7
-
8
- ## How to execute
9
-
10
- Option 1 — Using the built-in Read tool:
11
- The Read tool can directly read PDF files. Use `pages` parameter for large PDFs (max 20 pages per request).
12
-
13
- Option 2 — Full extraction via CLI (for better formatting or batch processing):
14
- ```bash
15
- npx --yes pdf-parse-cli "<PDF_PATH>"
16
- ```
17
-
18
- Option 3 — Using pdftotext (if available):
19
- ```bash
20
- pdftotext -layout "<PDF_PATH>" -
21
- ```
22
-
23
- ## Output
24
- Save the converted content to the session workspace as `library/{filename}.md` with:
25
- - Document title and source path at the top
26
- - Preserved heading structure where detectable
27
- - Tables converted to Markdown tables where possible
28
- - Page numbers as section markers
@@ -1,90 +0,0 @@
1
- # Skill: Playwright Browser Automation
2
-
3
- Automate web browser interactions — navigate pages, click buttons, fill forms, take screenshots, and extract content.
4
-
5
- ## When to use
6
- - Navigate to a URL and interact with it
7
- - Click buttons or links by their text or role
8
- - Fill form fields and submit data
9
- - Take screenshots of web pages
10
- - Extract text or structured data from pages
11
- - Automate multi-step web workflows (e.g. join a room, test a UI flow)
12
-
13
- ## How to execute
14
-
15
- Uses `@playwright/cli` via npx — no global install needed. Token-efficient: uses element references (e.g. `e15`) instead of pixel coordinates.
16
-
17
- ### First time only — install browser binaries
18
- ```bash
19
- npx playwright install chromium
20
- ```
21
-
22
- ### Step 1 — Open a URL
23
- ```bash
24
- npx @playwright/cli open https://localhost:3000
25
- ```
26
-
27
- ### Step 2 — Get page structure and element references
28
- ```bash
29
- npx @playwright/cli snapshot
30
- ```
31
- Returns an accessibility tree with element IDs like e1, e2, e15. Use these in subsequent commands.
32
-
33
- ### Step 3 — Interact with elements
34
- ```bash
35
- npx @playwright/cli click e15
36
- npx @playwright/cli fill e3 "some text"
37
- npx @playwright/cli press e3 Enter
38
- npx @playwright/cli select e7 "optionValue"
39
- npx @playwright/cli check e9
40
- npx @playwright/cli hover e12
41
- ```
42
-
43
- ### Take a screenshot
44
- ```bash
45
- npx @playwright/cli screenshot --path=/tmp/page.png
46
- ```
47
-
48
- ### Take a screenshot at a specific viewport size (mobile check)
49
- ```bash
50
- npx @playwright/cli screenshot --viewport-size=375,812 --path=/tmp/page-mobile.png
51
- ```
52
- Common mobile sizes: `375,812` (iPhone 14), `390,844` (iPhone 14 Pro), `412,915` (Pixel 7), `768,1024` (iPad).
53
-
54
- ### Close the browser
55
- ```bash
56
- npx @playwright/cli close
57
- ```
58
-
59
- ### Named sessions (persistent state across commands)
60
- ```bash
61
- npx @playwright/cli -s=myflow open https://localhost:3000
62
- npx @playwright/cli -s=myflow snapshot
63
- npx @playwright/cli -s=myflow fill e3 "abc123"
64
- npx @playwright/cli -s=myflow click e5
65
- npx @playwright/cli -s=myflow close
66
- ```
67
-
68
- ## Complete example — join Osborn voice room
69
- ```bash
70
- npx @playwright/cli open http://localhost:3000
71
- npx @playwright/cli snapshot
72
- npx @playwright/cli fill e3 "abc123"
73
- npx @playwright/cli click e4
74
- npx @playwright/cli screenshot --path=/tmp/osborn-joined.png
75
- npx @playwright/cli close
76
- ```
77
-
78
- ## Complete example — check mobile layout
79
- ```bash
80
- npx @playwright/cli open http://localhost:3000
81
- npx @playwright/cli screenshot --viewport-size=375,812 --path=/tmp/mobile-375.png
82
- npx @playwright/cli close
83
- ```
84
-
85
- ## Notes
86
- - Runs headless by default. Add --headed to see the browser window.
87
- - Install browsers first if needed: npx playwright install chromium
88
- - Element IDs are session-scoped — run snapshot again after page changes
89
- - Use `--viewport-size=WIDTH,HEIGHT` to simulate mobile screen sizes (e.g. `375,812` for iPhone 14)
90
- - Use `--storage-state=/tmp/state.json` to save and restore session state (cookies, localStorage) across runs