mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-22 04:38:24 +08:00
* fix(gstack-paths): guard CLAUDE_PLUGIN_DATA against cross-plugin contamination (#1569) gstack-paths previously trusted CLAUDE_PLUGIN_DATA as a fallback for GSTACK_STATE_ROOT whenever GSTACK_HOME was unset. When another plugin (e.g. Codex) persists its own CLAUDE_PLUGIN_DATA into the session env via CLAUDE_ENV_FILE, gstack picked it up and wrote checkpoints, analytics, and learnings into that plugin's directory. Anyone with the Codex plugin installed alongside gstack hit this silently. Fix: guard the CLAUDE_PLUGIN_DATA branch so it only fires when CLAUDE_PLUGIN_ROOT confirms we're running as the gstack plugin (path contains "gstack"). Skill installs fall through to \$HOME/.gstack. Contributed by @ElliotDrel via #1570. Closes #1569. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(gbrain-sync): sourceLocalPath handles wrapped {sources:[...]} shape from gbrain v0.20+ gbrain v0.20+ changed `gbrain sources list --json` to return {sources: [...]} instead of a flat array. sourceLocalPath crashed upstream with `list.find is not a function` on every /sync-gbrain invocation against modern gbrain. Accept both shapes for forward/backward compat, matching probeSource/sourcePageCount in lib/gbrain-sources.ts. Contributed by @jakehann11 via #1571. Closes #1567. Supersedes #1564 (@tonyjzhou, same fix, different shape — credit retained). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(brain-context-load): probe gbrain via execFile, not shell builtin (#1559) gbrainAvailable() used `execFileSync("command", ["-v", "gbrain"])`, which fails in any environment where the `command` builtin isn't on the spawned process's PATH (most non-interactive shells). The probe then reported gbrain as missing even when it was installed, and context-load silently skipped vector/list queries. Fix: probe `gbrain --version` directly with a 500ms timeout (matching the rest of the file's MCP_TIMEOUT_MS). Same semantics, works everywhere execFile works. Contributed by @jbetala7 via #1560. Closes #1559. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(gbrain-doctor): pin schema_version:2 doctor parse path (#1418) Adds an exec-path regression test that runs a fake gbrain shim emitting the v0.25+ doctor JSON shape (schema_version: 2, status: "warnings", exit 1 for health_score < 100, no top-level `engine` field). Confirms freshDetectEngineTier recovers stdout from the non-zero exit and falls back to GBRAIN_HOME/config.json for the engine label. The pre-existing test for #1415 only stripped gbrain from PATH; this test exercises the actual doctor parse path, closing the gap that codex's plan review flagged. Also documents the schema_version separation in lib/gbrain-local-status.ts: the local CacheEntry stays at version 1, distinct from the doctor-output schema_version which we accept across versions in gstack-memory-helpers. Closes #1418 (credit @mvanhorn for surfacing the doctor + schema_v2 collapse). The fix landed pre-emptively in v1.29.x; this commit pins it with a stronger test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(memory-ingest): pin put_page regression + scrub stale name from --help and comments (#1346) #1346 reported that gstack-memory-ingest still called the renamed gbrain put_page subcommand on gbrain v0.18+. The actual code migrated to `gbrain put` and later to batch `gbrain import <dir>` before this report landed — only documentation lag remained. This commit: - Updates the --help string ("Skip gbrain put calls (still updates state file)") so user-facing docs match the shipped subcommand - Updates two inline comments that still referenced the old name - Adds test/memory-ingest-no-put_page.test.ts: a regression pin that strips comments from bin/gstack-memory-ingest.ts and fails the build if "put_page" appears in any active code or string literal, plus a sanity check that the file still calls a supported gbrain page-write verb (put or import) Closes #1346. Reporter @kylma-code surfaced the doc lag; the original code migration credit is on the v1.27.x wave. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(resolvers): rewrite all gbrain put_page instructions to canonical put <slug> scripts/resolvers/gbrain.ts emitted user-facing copy-paste instructions using the renamed `gbrain put_page` subcommand across 10 skills (office-hours, investigate, plan-ceo-review, retro, plan-eng-review, ship, cso, design-consultation, fallback, entity-stub). Every gstack user copying those snippets hit "unknown command: put_page" on gbrain v0.18+. This commit: - Rewrites all 10 instruction templates to use `gbrain put <slug> --content "$(cat <<EOF...EOF)"` with title/tags moved into YAML frontmatter inside --content, matching the v0.18+ subcommand shape - Updates README.md and USING_GBRAIN_WITH_GSTACK.md "common commands" table to reference `gbrain put` and `gbrain get` - Adds test/resolvers-gbrain-put-rewrite.test.ts pinning two invariants: (a) resolver source ships only canonical instructions, (b) every tracked SKILL.md file is free of `gbrain put_page` CHANGELOG entries are deliberately left untouched (historical record). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(build): extract package.json build to scripts/build.sh for Windows Bun compat (#1538, #1537, #1530, #1457, #1561) Bun's Windows shell parser rejects multiple constructs the inline package.json build chain used: brace groups `{ cmd; }`, subshells with redirection `( git ... ) > path/.version`, and (in Bun 1.3.x) subshells near redirections in general. Every Windows install + every auto-upgrade since v1.34.2.0 has failed on `bun run build`. Extracts the build chain to scripts/build.sh and the .version writes to scripts/write-version-files.sh. POSIX-portable, no Bun shell parsing involved. Also adds Windows-specific bun.exe handling for non-ASCII PATHs (a separate Windows footgun where Bun's --compile fails when the binary lives under a path with non-ASCII chars). Updates test/build-script-shell-compat.test.ts to assert the new shape: no subshells with redirections anywhere in the build chain, and build delegates to scripts/build.sh which delegates .version writes. Contributed by @Charlie-El via #1544. Supersedes #1531 (@scarson, fixed in build helper), #1480 (@mikepsinn, partial overlap), #1460 (@realcarsonterry, brace-group fix subsumed) — credit retained. Closes #1538, #1537, #1530, #1457, #1561. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(windows): .exe glob in .gitignore + .exe extension resolution in find-browse (#1554) bun build --compile on Windows appends .exe to the output filename, producing browse.exe instead of browse. find-browse's existsSync probe only checked the bare path and returned null on Windows even when the binary was correctly built. .gitignore similarly only excluded the bare bin/gstack-global-discover path, leaving the .exe variant tracked. This commit: - .gitignore: changes `bin/gstack-global-discover` → `bin/gstack-global-discover*` so the Windows .exe variant is ignored - browse/src/find-browse.ts: adds isExecutable + findExecutable helpers that fall back to .exe/.cmd/.bat probing on Windows, mirroring the same helper already in make-pdf/src/browseClient.ts and pdftotext.ts Contributed by @Mike-E-Log via #1554. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(windows): add fresh-install E2E gate that runs bun run build on windows-latest Adds .github/workflows/windows-setup-e2e.yml as the gate that catches Bun shell-parser regressions in the build chain before they reach users. Triggers on PRs touching package.json, scripts/build.sh, scripts/write-version-files.sh, setup, browse cli/find-browse, or gstack-paths. What it verifies: 1. bun run build completes on Windows (the previously-broken path that #1538/#1537/#1530/#1457/#1561 reported) 2. All compiled binaries land on disk (browse.exe, find-browse.exe, design.exe, gstack-global-discover.exe) 3. find-browse resolves to the .exe variant on Windows (regression gate for #1554) 4. gstack-paths returns non-empty GSTACK_STATE_ROOT/PLAN_ROOT/TMP_ROOT on Windows (regression gate for #1570) Complements the existing windows-free-tests.yml (curated unit subset); this new workflow exercises the install path itself. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(codex): move diff scope into prompt instead of --base (Codex CLI 0.130+ argv conflict) (#1209) Codex CLI ≥ 0.130.0 rejects passing a custom prompt and --base together (mutually exclusive at argv level). Every /codex review, /review, and /ship structured Codex review call ended with an argv error before the model ran. Fix: scope the diff in prompt text using "Run git diff origin/<base>...HEAD 2>/dev/null || git diff <base>...HEAD" instead of `--base <base>`. Preserves the filesystem boundary instruction across all invocations and keeps Codex's review prompt tuning. Touches: - codex/SKILL.md.tmpl + regenerated codex/SKILL.md - scripts/resolvers/review.ts + regenerated review/SKILL.md, ship/SKILL.md - test/gen-skill-docs.test.ts: new regression that fails if any of the five known files still contain the prompt+--base shape - test/skill-validation.test.ts: corresponding negative + positive pin on the rendered SKILL.md files Contributed by @jbetala7 via #1209. Closes #1479. Supersedes #1527 (@mvanhorn — same intent, different patch shape, CONFLICTING) and #1449 (@Gujiassh — broader refactor, CONFLICTING). Credit retained in CHANGELOG. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(review): diff from git merge-base, not git diff origin/<base> (#1492) git diff origin/<base> shows everything since the common ancestor in both directions — it includes commits that landed on origin/<base> after this branch was created as deletions. That made /review and /ship's pre-landing structured review report inflated diff totals and flagged "removed" code that was actually still present in the working tree. Fix: compute DIFF_BASE via git merge-base origin/<base> HEAD and diff the working tree against that point. Same coverage of uncommitted edits, no phantom deletions from out-of-order base advancement. Applies to /review's Step 1 (diff existence check), Step 3 (get the diff), the build-on-intent scope-creep check, the structured review DIFF_INS/DIFF_DEL stats, and the Claude adversarial subagent prompt. Same change flows into ship/SKILL.md via the shared resolver. Touches: - review/SKILL.md.tmpl + regenerated review/SKILL.md, ship/SKILL.md - scripts/resolvers/review.ts - scripts/resolvers/review-army.ts Contributed by @mvanhorn via #1492. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(codex): pin filesystem-boundary preservation across all codex review surfaces (#1503, #1522) #1503 reported that the bare codex review --base path stripped the filesystem boundary instruction, letting Codex spend tokens reading .claude/skills/ and agents/. #1522 proposed adding a skill-path detector that switched to the custom-instructions route when the diff touched skill files. After C10 (#1209) restructured codex review to always carry the boundary in the prompt (the prompt+--base argv conflict forced the restructure), the skill-path detector becomes redundant — every default call already preserves the boundary. This commit pins the post-#1209 invariant with a test that fails the build if any future refactor strips the boundary from codex/SKILL.md, review/SKILL.md, or ship/SKILL.md. Closes #1503 by regression test. #1522 (@genisis0x) is superseded by #1209 (the prompt rewrite covers its safety concern); credit retained in CHANGELOG. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(skills): use command -v instead of which for codex detection (#1197) `which` is not on PATH in every shell — some Windows shells, BusyBox- only containers, and minimal CI images all fail when skills probe codex availability via `which codex`. `command -v` is a POSIX builtin and always available where the skill is running. Touched: - codex/SKILL.md.tmpl: CODEX_BIN=$(command -v codex || echo "") - scripts/resolvers/review.ts and scripts/resolvers/design.ts: 3 + 3 sites each rewritten to `command -v codex >/dev/null 2>&1` - Regenerated all 10 affected SKILL.md files (codex, review, ship, design-consultation, design-review, office-hours, plan-ceo-review, plan-design-review, plan-devex-review, plan-eng-review) - test/skill-validation.test.ts: updated pin + defensive regression test that fails if `which codex` returns to codex/SKILL.md - test/skill-e2e-plan.test.ts: updated summary regex Contributed by @mvanhorn via #1197. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(codex): surface non-zero exits so wrappers stop reading as silent stalls (#1467, #1327) When codex exits non-zero (parse errors, arg-shape breaks, model API errors that propagate as non-zero status), the calling agent previously saw an empty output and burned 30-60 minutes misdiagnosing as a silent model/API stall. The hang-detection block only caught exit 124 (the timeout-wrapper signal). Adds elif blocks in all four codex invocation sites (Review default, Challenge, Consult new-session, Consult resume) that: - Echo "[codex exit N] <stderr first line>" to stdout - Indent the first 20 stderr lines for inline context - Log codex_nonzero_exit telemetry tagged with the call site Contributed by @genisis0x via #1467. Closes #1327. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(design): disclose OpenAI key source + warn on cwd .env match (#1278, closes #1248) The design binary previously called process.env.OPENAI_API_KEY without checking where the key came from. If a user ran $D inside someone else's project that had OPENAI_API_KEY in its .env, the resulting generation billed that project's account. Silent and irreversible. Fix: resolveApiKeyInfo() returns both the key and its source. When the env-var path matches an OPENAI_API_KEY entry in the current directory's .env, .env.<NODE_ENV>, or .env.local file, we set a warning. requireApiKey() prints "Using OpenAI key from <source>" plus the warning before the run — never the key itself. Adds 6 unit tests covering: config-vs-env precedence, env-only (no match), env+cwd .env match, quoted/exported values, value-mismatch (no false positive), and the no-leak invariant for requireApiKey stderr output. Contributed by @jbetala7 via #1278. Closes #1248. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(browse): guard full-page screenshots against Anthropic vision API >2000px brick (#1214) Full-page screenshots of tall pages routinely exceeded 2000px on the longest dimension, silently bricking the agent's session: the resulting base64 reached the Anthropic vision API which rejected the oversized image, leaving the agent burning turns on a useless blob with no stderr trace from the browse side. Adds browse/src/screenshot-size-guard.ts as a shared helper: - guardScreenshotBuffer(buf) → downscales in-memory if max(w,h) > 2000 - guardScreenshotPath(path) → file-mode variant that rewrites in place - Aspect ratio preserved via sharp's resize fit:inside - Stderr diagnostic on any downscale so callers can see when it fired - Lazy sharp import so non-screenshot paths pay no startup cost Wires the guard into all three full-page callsites codex review flagged: - browse/src/snapshot.ts: annotated + heatmap fullPage captures - browse/src/meta-commands.ts: screenshot command (path + base64 fullPage modes) plus the responsive 3-viewport sweep - browse/src/write-commands.ts: prettyscreenshot fullPage path Covers seven unit cases (pass-through, downscale, aspect ratio, exactly-2000px edge, file-mode rewrite) plus a static invariant test that fails the build if any of the three callsites stops importing the guard. Closes #1214. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(security): add Node sidecar entry for L4 prompt-injection classifier (#1370) The L4 TestSavant classifier in browse/src/security-classifier.ts can't be imported into the compiled browse server (onnxruntime-node dlopen fails from Bun's compile extract dir per CLAUDE.md). The agent that used to host it (sidebar-agent.ts) was removed when the PTY proved out — leaving the classifier file shipped but with zero callers. Exactly the gap codex flagged in #1370. Adds browse/src/security-sidecar-entry.ts: a Node script that runs the classifier as a subprocess of the browse server. It reads NDJSON requests from stdin and writes id-correlated NDJSON responses to stdout, supporting: - op: "scan-page-content" — full L4 classifier scan - op: "ping" — liveness probe for the client's health check - op: "status" — classifier readiness (used by /pty-inject-scan to surface l4 { available: bool } in its response) Plus browse/src/find-security-sidecar.ts: a resolver that locates node + the bundled JS entry (browse/dist/security-sidecar.js, built in a follow-up package.json change) or falls back to the dev TS entry. Returns null cleanly when node isn't on PATH so the calling endpoint can degrade per D7 (extension WARN + user confirm). C17 of the security-stack wave. C18 adds the IPC client + lifecycle management; C19 wires the endpoint; C20 routes the extension through it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(security): sidecar IPC client with lifecycle + circuit breaker (#1370) Adds browse/src/security-sidecar-client.ts to manage the Node L4 classifier subprocess from the compiled browse server: - Lazy spawn on first scan; reuses the same process across requests - Id-correlated request/response via NDJSON over stdio - 5s default per-scan timeout; 64KB payload cap (short-circuits before spawn so oversized requests don't waste a process) - 3-in-10-minutes respawn cap → trips circuit breaker; subsequent scans throw immediately so the /pty-inject-scan endpoint can surface l4 { available: false } to the extension and degrade to WARN+confirm - process.on('exit') sends SIGTERM to the child for clean teardown - isSidecarAvailable() lets the endpoint probe before scan calls so the response shape reflects degraded mode honestly Unit tests cover the payload cap, the availability probe, and the breaker-doesn't-crash invariant under repeated rejected calls. C18 of the security-stack wave. C19 adds POST /pty-inject-scan; C20 routes the extension through it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(security): add POST /pty-inject-scan endpoint for pre-PTY-inject scans (#1370) The sidebar's gstackInjectToTerminal callers (toolbar Cleanup, Inspector "Send to Code") were piping page-derived text directly into the live claude PTY with ZERO classifier processing — the gap codex flagged in #1370. The documented sidebar security stack had a hole the size of every Cleanup-button click. Adds POST /pty-inject-scan to browse/src/server.ts: - Local-only binding (NOT in TUNNEL_PATHS — tunnel attempts get the general 404 path; never reaches the scan logic) - Root-token auth via existing validateAuth() — 401 on unauth - 64KB request cap → 413 + payload-too-large body - 5s scan timeout via sidecar client - URL-blocklist forced to BLOCK in PTY context (page-derived REPL input is higher-risk than ordinary tool output) - L4 ML classifier via the sidecar when available; degrades to WARN per D7 when sidecar is unavailable - Response goes through JSON.stringify(..., sanitizeReplacer) per v1.38.0.0 Unicode-egress hardening - Imports only from security-sidecar-client.ts, never directly from security-classifier.ts (which would brick the compiled Bun binary) Seven static-invariant tests pin the POST verb, auth gate, 64KB cap, tunnel-listener exclusion, sanitizeReplacer wrapping, l4 availability shape, and the no-direct-classifier-import rule. C19 of the security-stack wave. C20 routes the extension through it; C21 adds the invariant AST check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(extension): route gstackInjectToTerminal through /pty-inject-scan (#1370) Closes the documented-vs-shipped gap codex flagged in #1370. The sidebar's two PTY-injection call sites (Inspector "Send to Code" and toolbar Cleanup) now pre-scan via the new /pty-inject-scan endpoint before writing to the live claude REPL. Adds window.gstackScanForPTYInject(text, origin) to extension/sidepanel-terminal.js: - Async, returns { allow, verdict, reasons, l4 } - POST to /pty-inject-scan with the existing root-token auth - WARN+confirm on scan failure (network down, sidecar absent, etc.) rather than silent PASS — D7 honest-degradation gstackInjectToTerminal stays synchronous, returns boolean. Per D6: keeping the inject sync means existing `const ok = ...?.()` callers don't break, and the invariant test in test/extension-pty-inject-invariant.test.ts can statically pin that every call goes through the scan first. extension/sidepanel.js call sites updated: - inspectorSendBtn click → await scan, BLOCK drops + WARN prompts via window.confirm, PASS injects silently - runCleanup() → same flow. Static cleanup prompt always PASSes but still routes through scan to honor the invariant. C20 of the security-stack wave. C21 adds the static invariant test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(security): invariant — extension PTY inject must be scan-gated (#1370) Static-analysis invariant test that fails the build if any extension/*.js path calls window.gstackInjectToTerminal without a preceding window.gstackScanForPTYInject in the same enclosing function. Closes the documented-vs-shipped gap codex demanded a machine check on. Rules: - Rule 1: any file that calls inject must also reference scan - Rule 2: in the enclosing function (function declaration, arrow, async (), event handler), a scan call must appear before the inject call by source position - Exemption: sidepanel-terminal.js (the file that DEFINES the inject function) is exempt from Rule 2 since the definition is not a call Plus two structural checks: - sidepanel-terminal.js defines both the inject and scan functions - inject stays SYNCHRONOUS (no `async` modifier) per D6 — async would silently break the `const ok = ...?.()` pattern at every caller C21 of the security-stack wave. The sidecar architecture (#1370) is complete: server-side L1-L3 + L4-via-sidecar (C17+C18+C19), extension pre-scan wiring (C20), and now the regression gate (C21). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(browse): opt-in extended stealth mode with 6 detection-vector patches (#1112) Rebases @garrytan's PR #1112 (Apr 2026, abandoned) onto the current browse/src/stealth.ts contract. The existing minimal "codex narrowed" stealth (webdriver-mask + AutomationControlled launch arg) stays the default. PR #1112's six additional patches are added behind an opt-in GSTACK_STEALTH=extended env flag. Extended-mode patches (applied AFTER the default mask, in order): 1. delete navigator.webdriver from prototype (not just the getter — detectors check `"webdriver" in navigator`) 2. WebGL renderer spoof to Apple M1 Pro (SwiftShader was the #1 software-GPU tell in containers) 3. navigator.plugins returns a PluginArray-prototype-passing array with MimeType objects and namedItem() 4. window.chrome populated with chrome.app, chrome.runtime, chrome.loadTimes(), chrome.csi() with realistic shapes 5. navigator.mediaDevices backfilled when headless drops it 6. CDP cdc_*-prefixed window globals cleared Why opt-in: the default mode's contract is fingerprint CONSISTENCY, which protects against detectors that flag spoofing mismatch. Extended mode actively lies about the environment; sites that reflect on these properties can break. Users who hit detection in default mode can flip GSTACK_STEALTH=extended for SannySoft 100% pass-rate. Twenty unit tests pin the env-flag semantics, all six patches' code presence, and the applyStealth wiring order. Live SannySoft pass-rate verification stays in the periodic-tier E2E suite. Contributed by @garrytan via #1112 (rebased — original PR opened before the codex-narrowed minimum landed; rebase preserves the narrowed default while adding the SannySoft-passing path as opt-in). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(fixtures): regenerate ship-SKILL.md golden baselines after C10-C13 + C16 templates Updates the three ship-SKILL.md golden baselines (claude, codex, factory hosts) to match the new shape produced by: - C10 #1209 codex argv (prompt + diff scope, no --base) - C11 #1492 merge-base diff (DIFF_BASE= preamble) - C13 #1197 command -v for codex detection - C12 + boundary preservation per regen-enforcing test Per CLAUDE.md SKILL.md workflow: edit the .tmpl, run gen:skill-docs, commit the regenerated outputs together. Goldens are part of the regen contract — without this commit, test/host-config.test.ts' golden-baseline checks fail with the diff codex review surfaced. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(release): v1.41.0.0 — Daegu wave (24 bisect commits, 14 user-facing fixes) Bumps VERSION 1.40.0.0 → 1.41.0.0. CHANGELOG entry follows the release-summary format in CLAUDE.md: two-line headline, lead paragraph, "The numbers that matter" table, "What this means for builders" closer, then itemized Added/Changed/Fixed/For contributors with inline credit to every PR author and original issue reporter. Scale-aware bump per CLAUDE.md: 24 commits, ~6000 LOC net, substantial new capability across security (PTY sidecar wiring), install (Windows build chain), compat (gbrain 0.18-0.35, Codex CLI 0.130+), and quality (screenshot guard, design key disclosure, extended stealth opt-in). MINOR is the right call. Closes for users: #1567, #1559, #1569, #1346, #1418, #1538, #1537, #1530, #1457, #1561, #1554, #1479, #1503, #1248, #1214, #1370, #1327, #1193 pattern, #1152 pattern. Credit retained inline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(find-browse): resolve source-checkout layout <git-root>/browse/dist/browse[.exe] windows-setup-e2e.yml runs `bun browse/src/find-browse.ts` against a freshly-built repo where binaries land at browse/dist/browse.exe (no .claude/skills/gstack/ install layout). The previous markers chain only matched .codex/.agents/.claude prefixed paths, so find-browse exited "not found" even when the binary was present. Adds a source-checkout fallback after the marker scan: if no installed layout resolves but <git-root>/browse/dist/browse[.exe] exists, return that. Three real callers hit this path: - gstack repo dev workflow before `./setup` runs - windows-setup-e2e.yml CI (the breakage that surfaced this) - make-pdf consumers running from a sibling source checkout Smoke-verified: a fresh git repo with browse/dist/browse on disk now resolves through the source-checkout branch (was returning null before this commit). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(release): bump v1.41.0.0 → v1.42.0.0 to clear queue collision with #1574 The version-gate workflow flagged a collision: PR #1574 (garrytan/colombo-v3) already claims v1.41.0.0, and #1592 (fix/audit-critical-high-bugs) claims v1.41.1.0. Per CLAUDE.md's workspace-aware ship rule, queue-advancing past a claimed version within the same bump level is permitted — MINOR work landing on top of a queued MINOR still reads as MINOR relative to main. Util's suggested next slot is v1.42.0.0; taking it. CHANGELOG entry header bumped + dated 2026-05-19; entry body unchanged (same wave content, same credit list). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1086 lines
42 KiB
JavaScript
1086 lines
42 KiB
JavaScript
/**
|
|
* gstack browse — Side Panel
|
|
*
|
|
* Terminal pane (default): live claude PTY via xterm.js, driven by
|
|
* sidepanel-terminal.js. The chat queue + sidebar-agent.ts were ripped
|
|
* in favor of the interactive REPL — no more one-shot claude -p.
|
|
*
|
|
* Debug tabs (behind the `debug` toggle): activity feed (SSE) + refs +
|
|
* inspector. Quick-actions toolbar (Cleanup / Screenshot / Cookies)
|
|
* lives at the top of the Terminal pane.
|
|
*/
|
|
|
|
const NAV_COMMANDS = new Set(['goto', 'back', 'forward', 'reload']);
|
|
const INTERACTION_COMMANDS = new Set(['click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait', 'upload']);
|
|
const OBSERVE_COMMANDS = new Set(['snapshot', 'screenshot', 'diff', 'console', 'network', 'text', 'html', 'links', 'forms', 'accessibility', 'cookies', 'storage', 'perf']);
|
|
|
|
let lastId = 0;
|
|
let eventSource = null;
|
|
let serverUrl = null;
|
|
let serverToken = null;
|
|
let connState = 'disconnected'; // disconnected | connected | reconnecting | dead
|
|
let reconnectAttempts = 0;
|
|
let reconnectTimer = null;
|
|
const MAX_RECONNECT_ATTEMPTS = 30; // 30 * 2s = 60s before showing "dead"
|
|
|
|
// Auth headers for sidebar endpoints
|
|
function authHeaders() {
|
|
const h = { 'Content-Type': 'application/json' };
|
|
if (serverToken) h['Authorization'] = `Bearer ${serverToken}`;
|
|
return h;
|
|
}
|
|
|
|
// ─── Connection State Machine ─────────────────────────────────────
|
|
|
|
function setConnState(state) {
|
|
const prev = connState;
|
|
connState = state;
|
|
const banner = document.getElementById('conn-banner');
|
|
const bannerText = document.getElementById('conn-banner-text');
|
|
const bannerActions = document.getElementById('conn-banner-actions');
|
|
|
|
if (state === 'connected') {
|
|
if (prev === 'reconnecting' || prev === 'dead') {
|
|
// Show "reconnected" toast that fades
|
|
banner.style.display = '';
|
|
banner.className = 'conn-banner reconnected';
|
|
bannerText.textContent = 'Reconnected';
|
|
bannerActions.style.display = 'none';
|
|
setTimeout(() => { banner.style.display = 'none'; }, 5000);
|
|
} else {
|
|
banner.style.display = 'none';
|
|
}
|
|
reconnectAttempts = 0;
|
|
if (reconnectTimer) { clearInterval(reconnectTimer); reconnectTimer = null; }
|
|
} else if (state === 'reconnecting') {
|
|
banner.style.display = '';
|
|
banner.className = 'conn-banner reconnecting';
|
|
bannerText.textContent = `Reconnecting... (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`;
|
|
bannerActions.style.display = 'none';
|
|
} else if (state === 'dead') {
|
|
banner.style.display = '';
|
|
banner.className = 'conn-banner dead';
|
|
bannerText.textContent = 'Server offline';
|
|
bannerActions.style.display = '';
|
|
if (reconnectTimer) { clearInterval(reconnectTimer); reconnectTimer = null; }
|
|
} else {
|
|
banner.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function startReconnect() {
|
|
if (reconnectTimer) return;
|
|
setConnState('reconnecting');
|
|
reconnectTimer = setInterval(() => {
|
|
reconnectAttempts++;
|
|
if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
|
|
setConnState('dead');
|
|
return;
|
|
}
|
|
setConnState('reconnecting');
|
|
tryConnect();
|
|
}, 2000);
|
|
}
|
|
|
|
|
|
// ─── Chat path ripped ────────────────────────────────────────────
|
|
// Chat queue + sendMessage + pollChat + switchChatTab + browser-tabs
|
|
// strip + security banner all lived here. Replaced by the interactive
|
|
// claude PTY in sidepanel-terminal.js (and terminal-agent.ts on the
|
|
// server side).
|
|
|
|
// ─── Reload Sidebar ─────────────────────────────────────────────
|
|
document.getElementById('reload-sidebar').addEventListener('click', () => {
|
|
location.reload();
|
|
});
|
|
|
|
// ─── Copy Cookies ───────────────────────────────────────────────
|
|
document.getElementById('chat-cookies-btn').addEventListener('click', async () => {
|
|
if (!serverUrl) return;
|
|
// Navigate the browser to the cookie picker page hosted by the browse server
|
|
try {
|
|
await fetch(`${serverUrl}/command`, {
|
|
method: 'POST',
|
|
headers: authHeaders(),
|
|
body: JSON.stringify({ command: 'goto', args: [`${serverUrl}/cookie-picker`] }),
|
|
});
|
|
} catch (err) {
|
|
console.error('[gstack sidebar] Failed to open cookie picker:', err.message);
|
|
}
|
|
});
|
|
|
|
// ─── Debug Tabs ─────────────────────────────────────────────────
|
|
|
|
const debugToggle = document.getElementById('debug-toggle');
|
|
const debugTabs = document.getElementById('debug-tabs');
|
|
const closeDebug = document.getElementById('close-debug');
|
|
let debugOpen = false;
|
|
|
|
// The Terminal pane is the only primary surface; Activity / Refs / Inspector
|
|
// are debug overlays behind the `debug` toggle. Closing debug returns to
|
|
// the Terminal pane, which is always present.
|
|
const PRIMARY_PANE_ID = 'tab-terminal';
|
|
|
|
function showPrimaryPane() {
|
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
document.getElementById(PRIMARY_PANE_ID).classList.add('active');
|
|
document.querySelectorAll('.debug-tabs .tab').forEach(t => t.classList.remove('active'));
|
|
}
|
|
|
|
debugToggle.addEventListener('click', () => {
|
|
debugOpen = !debugOpen;
|
|
debugToggle.classList.toggle('active', debugOpen);
|
|
debugTabs.style.display = debugOpen ? 'flex' : 'none';
|
|
if (!debugOpen) showPrimaryPane();
|
|
});
|
|
|
|
closeDebug.addEventListener('click', () => {
|
|
debugOpen = false;
|
|
debugToggle.classList.remove('active');
|
|
debugTabs.style.display = 'none';
|
|
showPrimaryPane();
|
|
});
|
|
|
|
document.querySelectorAll('.debug-tabs .tab:not(.close-debug)').forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
document.querySelectorAll('.debug-tabs .tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active');
|
|
|
|
if (tab.dataset.tab === 'refs') fetchRefs();
|
|
});
|
|
});
|
|
|
|
// ─── Activity Feed ──────────────────────────────────────────────
|
|
|
|
function getEntryClass(entry) {
|
|
if (entry.status === 'error') return 'error';
|
|
if (entry.type === 'command_start') return 'pending';
|
|
const cmd = entry.command || '';
|
|
if (NAV_COMMANDS.has(cmd)) return 'nav';
|
|
if (INTERACTION_COMMANDS.has(cmd)) return 'interaction';
|
|
if (OBSERVE_COMMANDS.has(cmd)) return 'observe';
|
|
return '';
|
|
}
|
|
|
|
function formatTime(ts) {
|
|
const d = new Date(ts);
|
|
return d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
}
|
|
|
|
let pendingEntries = new Map();
|
|
|
|
function createEntryElement(entry) {
|
|
const div = document.createElement('div');
|
|
div.className = `activity-entry ${getEntryClass(entry)}`;
|
|
div.setAttribute('role', 'article');
|
|
div.tabIndex = 0;
|
|
|
|
const argsText = entry.args ? entry.args.join(' ') : '';
|
|
const statusIcon = entry.status === 'ok' ? '\u2713' : entry.status === 'error' ? '\u2717' : '';
|
|
const statusClass = entry.status === 'ok' ? 'ok' : entry.status === 'error' ? 'err' : '';
|
|
const duration = entry.duration ? `${entry.duration}ms` : '';
|
|
|
|
div.innerHTML = `
|
|
<div class="entry-header">
|
|
<span class="entry-time">${formatTime(entry.timestamp)}</span>
|
|
<span class="entry-command">${escapeHtml(entry.command || entry.type)}</span>
|
|
</div>
|
|
${argsText ? `<div class="entry-args">${escapeHtml(argsText)}</div>` : ''}
|
|
${entry.type === 'command_end' ? `
|
|
<div class="entry-status">
|
|
<span class="${statusClass}">${statusIcon}</span>
|
|
<span class="duration">${duration}</span>
|
|
</div>
|
|
` : ''}
|
|
${entry.result ? `
|
|
<div class="entry-detail">
|
|
<div class="entry-result">${escapeHtml(entry.result)}</div>
|
|
</div>
|
|
` : ''}
|
|
`;
|
|
|
|
div.addEventListener('click', () => div.classList.toggle('expanded'));
|
|
return div;
|
|
}
|
|
|
|
function addEntry(entry) {
|
|
const feed = document.getElementById('activity-feed');
|
|
const empty = document.getElementById('empty-state');
|
|
if (empty) empty.style.display = 'none';
|
|
|
|
if (entry.type === 'command_end') {
|
|
for (const [id, el] of pendingEntries) {
|
|
if (el.querySelector('.entry-command')?.textContent === entry.command) {
|
|
el.remove();
|
|
pendingEntries.delete(id);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
const el = createEntryElement(entry);
|
|
feed.appendChild(el);
|
|
if (entry.type === 'command_start') pendingEntries.set(entry.id, el);
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
|
|
|
if (entry.url) document.getElementById('footer-url')?.textContent && (document.getElementById('footer-url').textContent = new URL(entry.url).hostname);
|
|
lastId = Math.max(lastId, entry.id);
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
// DOM text-node serialization escapes &, <, > but NOT " or '. Call sites
|
|
// that interpolate escapeHtml output inside an attribute value (title="...",
|
|
// data-x="...") need those escaped too or an attacker-controlled value can
|
|
// break out of the attribute. Add both manually.
|
|
return div.innerHTML
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
// ─── SSE Connection ─────────────────────────────────────────────
|
|
|
|
// Fetch a view-only SSE session cookie before opening EventSource.
|
|
// EventSource can't send Authorization headers, and putting the root
|
|
// token in the URL (the old ?token= path) leaks it to logs, referer
|
|
// headers, and browser history. POST /sse-session issues an HttpOnly
|
|
// SameSite=Strict cookie scoped to SSE reads only; withCredentials:true
|
|
// on EventSource makes the browser send it back.
|
|
async function ensureSseSessionCookie() {
|
|
if (!serverUrl || !serverToken) return false;
|
|
try {
|
|
const resp = await fetch(`${serverUrl}/sse-session`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Authorization': `Bearer ${serverToken}` },
|
|
});
|
|
return resp.ok;
|
|
} catch (err) {
|
|
console.warn('[gstack sidebar] Failed to mint SSE session cookie:', err && err.message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function connectSSE() {
|
|
if (!serverUrl) return;
|
|
if (eventSource) { eventSource.close(); eventSource = null; }
|
|
|
|
await ensureSseSessionCookie();
|
|
const url = `${serverUrl}/activity/stream?after=${lastId}`;
|
|
eventSource = new EventSource(url, { withCredentials: true });
|
|
|
|
eventSource.addEventListener('activity', (e) => {
|
|
try { addEntry(JSON.parse(e.data)); } catch (err) {
|
|
console.error('[gstack sidebar] Failed to parse activity event:', err.message);
|
|
}
|
|
});
|
|
|
|
eventSource.addEventListener('gap', (e) => {
|
|
try {
|
|
const data = JSON.parse(e.data);
|
|
const feed = document.getElementById('activity-feed');
|
|
const banner = document.createElement('div');
|
|
banner.className = 'gap-banner';
|
|
banner.textContent = `Missed ${data.availableFrom - data.gapFrom} events`;
|
|
feed.appendChild(banner);
|
|
} catch (err) {
|
|
console.error('[gstack sidebar] Failed to parse gap event:', err.message);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ─── Refs Tab ───────────────────────────────────────────────────
|
|
|
|
async function fetchRefs() {
|
|
if (!serverUrl) return;
|
|
try {
|
|
const headers = {};
|
|
if (serverToken) headers['Authorization'] = `Bearer ${serverToken}`;
|
|
const resp = await fetch(`${serverUrl}/refs`, { signal: AbortSignal.timeout(3000), headers });
|
|
if (!resp.ok) return;
|
|
const data = await resp.json();
|
|
|
|
const list = document.getElementById('refs-list');
|
|
const empty = document.getElementById('refs-empty');
|
|
const footer = document.getElementById('refs-footer');
|
|
|
|
if (!data.refs || data.refs.length === 0) {
|
|
empty.style.display = '';
|
|
list.innerHTML = '';
|
|
footer.textContent = '';
|
|
return;
|
|
}
|
|
|
|
empty.style.display = 'none';
|
|
list.innerHTML = data.refs.map(r => `
|
|
<div class="ref-row">
|
|
<span class="ref-id">${escapeHtml(r.ref)}</span>
|
|
<span class="ref-role">${escapeHtml(r.role)}</span>
|
|
<span class="ref-name">"${escapeHtml(r.name)}"</span>
|
|
</div>
|
|
`).join('');
|
|
footer.textContent = `${data.refs.length} refs`;
|
|
} catch (err) {
|
|
console.error('[gstack sidebar] Failed to fetch refs:', err.message);
|
|
}
|
|
}
|
|
|
|
// ─── Inspector Tab ──────────────────────────────────────────────
|
|
|
|
let inspectorPickerActive = false;
|
|
let inspectorData = null; // last inspect result
|
|
let inspectorModifications = []; // tracked style changes
|
|
let inspectorSSE = null;
|
|
|
|
// Inspector DOM refs
|
|
const inspectorPickBtn = document.getElementById('inspector-pick-btn');
|
|
const inspectorSelected = document.getElementById('inspector-selected');
|
|
const inspectorModeBadge = document.getElementById('inspector-mode-badge');
|
|
const inspectorEmpty = document.getElementById('inspector-empty');
|
|
const inspectorLoading = document.getElementById('inspector-loading');
|
|
const inspectorError = document.getElementById('inspector-error');
|
|
const inspectorPanels = document.getElementById('inspector-panels');
|
|
const inspectorBoxmodel = document.getElementById('inspector-boxmodel');
|
|
const inspectorRules = document.getElementById('inspector-rules');
|
|
const inspectorRuleCount = document.getElementById('inspector-rule-count');
|
|
const inspectorComputed = document.getElementById('inspector-computed');
|
|
const inspectorQuickedit = document.getElementById('inspector-quickedit');
|
|
const inspectorSend = document.getElementById('inspector-send');
|
|
const inspectorSendBtn = document.getElementById('inspector-send-btn');
|
|
|
|
// Pick button
|
|
inspectorPickBtn.addEventListener('click', () => {
|
|
if (inspectorPickerActive) {
|
|
inspectorPickerActive = false;
|
|
inspectorPickBtn.classList.remove('active');
|
|
chrome.runtime.sendMessage({ type: 'stopInspector' });
|
|
} else {
|
|
inspectorPickerActive = true;
|
|
inspectorPickBtn.classList.add('active');
|
|
inspectorShowLoading(false); // don't show loading yet, just activate
|
|
chrome.runtime.sendMessage({ type: 'startInspector' }, (result) => {
|
|
if (result?.error) {
|
|
inspectorPickerActive = false;
|
|
inspectorPickBtn.classList.remove('active');
|
|
inspectorShowError(result.error);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
function inspectorShowEmpty() {
|
|
inspectorEmpty.style.display = '';
|
|
inspectorLoading.style.display = 'none';
|
|
inspectorError.style.display = 'none';
|
|
inspectorPanels.style.display = 'none';
|
|
inspectorSend.style.display = 'none';
|
|
}
|
|
|
|
function inspectorShowLoading(show) {
|
|
if (show) {
|
|
inspectorEmpty.style.display = 'none';
|
|
inspectorLoading.style.display = '';
|
|
inspectorError.style.display = 'none';
|
|
inspectorPanels.style.display = 'none';
|
|
} else {
|
|
inspectorLoading.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function inspectorShowError(message) {
|
|
inspectorEmpty.style.display = 'none';
|
|
inspectorLoading.style.display = 'none';
|
|
inspectorError.style.display = '';
|
|
inspectorError.textContent = message;
|
|
inspectorPanels.style.display = 'none';
|
|
}
|
|
|
|
function inspectorShowData(data) {
|
|
inspectorData = data;
|
|
inspectorModifications = [];
|
|
inspectorEmpty.style.display = 'none';
|
|
inspectorLoading.style.display = 'none';
|
|
inspectorError.style.display = 'none';
|
|
inspectorPanels.style.display = '';
|
|
inspectorSend.style.display = '';
|
|
|
|
// Update toolbar
|
|
const tag = data.tagName || '?';
|
|
const cls = data.classes && data.classes.length > 0 ? '.' + data.classes.join('.') : '';
|
|
const idStr = data.id ? '#' + data.id : '';
|
|
inspectorSelected.textContent = `<${tag}>${idStr}${cls}`;
|
|
inspectorSelected.title = data.selector;
|
|
|
|
// Mode badge
|
|
if (data.mode === 'basic') {
|
|
inspectorModeBadge.textContent = 'Basic mode';
|
|
inspectorModeBadge.style.display = '';
|
|
inspectorModeBadge.className = 'inspector-mode-badge basic';
|
|
} else if (data.mode === 'cdp') {
|
|
inspectorModeBadge.textContent = 'CDP';
|
|
inspectorModeBadge.style.display = '';
|
|
inspectorModeBadge.className = 'inspector-mode-badge cdp';
|
|
} else {
|
|
inspectorModeBadge.style.display = 'none';
|
|
}
|
|
|
|
// Render sections
|
|
renderBoxModel(data);
|
|
renderMatchedRules(data);
|
|
renderComputedStyles(data);
|
|
renderQuickEdit(data);
|
|
updateSendButton();
|
|
}
|
|
|
|
// ─── Box Model Rendering ────────────────────────────────────────
|
|
|
|
function renderBoxModel(data) {
|
|
const box = data.basicData?.boxModel || data.boxModel;
|
|
if (!box) { inspectorBoxmodel.innerHTML = '<span class="inspector-no-data">No box model data</span>'; return; }
|
|
|
|
const m = box.margin || {};
|
|
const b = box.border || {};
|
|
const p = box.padding || {};
|
|
const c = box.content || {};
|
|
|
|
inspectorBoxmodel.innerHTML = `
|
|
<div class="boxmodel-margin">
|
|
<span class="boxmodel-label">margin</span>
|
|
<span class="boxmodel-value boxmodel-top">${fmtBoxVal(m.top)}</span>
|
|
<span class="boxmodel-value boxmodel-right">${fmtBoxVal(m.right)}</span>
|
|
<span class="boxmodel-value boxmodel-bottom">${fmtBoxVal(m.bottom)}</span>
|
|
<span class="boxmodel-value boxmodel-left">${fmtBoxVal(m.left)}</span>
|
|
<div class="boxmodel-border">
|
|
<span class="boxmodel-label">border</span>
|
|
<span class="boxmodel-value boxmodel-top">${fmtBoxVal(b.top)}</span>
|
|
<span class="boxmodel-value boxmodel-right">${fmtBoxVal(b.right)}</span>
|
|
<span class="boxmodel-value boxmodel-bottom">${fmtBoxVal(b.bottom)}</span>
|
|
<span class="boxmodel-value boxmodel-left">${fmtBoxVal(b.left)}</span>
|
|
<div class="boxmodel-padding">
|
|
<span class="boxmodel-label">padding</span>
|
|
<span class="boxmodel-value boxmodel-top">${fmtBoxVal(p.top)}</span>
|
|
<span class="boxmodel-value boxmodel-right">${fmtBoxVal(p.right)}</span>
|
|
<span class="boxmodel-value boxmodel-bottom">${fmtBoxVal(p.bottom)}</span>
|
|
<span class="boxmodel-value boxmodel-left">${fmtBoxVal(p.left)}</span>
|
|
<div class="boxmodel-content">
|
|
<span>${Math.round(c.width || 0)} x ${Math.round(c.height || 0)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function fmtBoxVal(v) {
|
|
if (v === undefined || v === null) return '-';
|
|
const n = typeof v === 'number' ? v : parseFloat(v);
|
|
if (isNaN(n) || n === 0) return '0';
|
|
return Math.round(n * 10) / 10;
|
|
}
|
|
|
|
// ─── Matched Rules Rendering ────────────────────────────────────
|
|
|
|
function renderMatchedRules(data) {
|
|
const rules = data.matchedRules || data.basicData?.matchedRules || [];
|
|
inspectorRuleCount.textContent = rules.length > 0 ? `(${rules.length})` : '';
|
|
|
|
if (rules.length === 0) {
|
|
inspectorRules.innerHTML = '<div class="inspector-no-data">No matched rules</div>';
|
|
return;
|
|
}
|
|
|
|
// Separate UA rules from author rules
|
|
const authorRules = [];
|
|
const uaRules = [];
|
|
for (const rule of rules) {
|
|
if (rule.origin === 'user-agent' || rule.isUA) {
|
|
uaRules.push(rule);
|
|
} else {
|
|
authorRules.push(rule);
|
|
}
|
|
}
|
|
|
|
let html = '';
|
|
|
|
// Author rules (expanded)
|
|
for (const rule of authorRules) {
|
|
html += renderRule(rule, false);
|
|
}
|
|
|
|
// UA rules (collapsed by default)
|
|
if (uaRules.length > 0) {
|
|
html += `
|
|
<div class="inspector-ua-rules">
|
|
<button class="inspector-ua-toggle collapsed" aria-expanded="false">
|
|
<span class="inspector-toggle-arrow">▶</span>
|
|
User Agent (${uaRules.length})
|
|
</button>
|
|
<div class="inspector-ua-body collapsed">
|
|
`;
|
|
for (const rule of uaRules) {
|
|
html += renderRule(rule, true);
|
|
}
|
|
html += '</div></div>';
|
|
}
|
|
|
|
inspectorRules.innerHTML = html;
|
|
|
|
// Bind UA toggle
|
|
const uaToggle = inspectorRules.querySelector('.inspector-ua-toggle');
|
|
if (uaToggle) {
|
|
uaToggle.addEventListener('click', () => {
|
|
const body = inspectorRules.querySelector('.inspector-ua-body');
|
|
const isCollapsed = uaToggle.classList.contains('collapsed');
|
|
uaToggle.classList.toggle('collapsed', !isCollapsed);
|
|
uaToggle.setAttribute('aria-expanded', isCollapsed);
|
|
uaToggle.querySelector('.inspector-toggle-arrow').innerHTML = isCollapsed ? '▼' : '▶';
|
|
body.classList.toggle('collapsed', !isCollapsed);
|
|
});
|
|
}
|
|
}
|
|
|
|
function renderRule(rule, isUA) {
|
|
const selectorText = escapeHtml(rule.selector || '');
|
|
const truncatedSelector = selectorText.length > 35 ? selectorText.slice(0, 35) + '...' : selectorText;
|
|
const source = rule.source || '';
|
|
const sourceDisplay = source.includes('/') ? source.split('/').pop() : source;
|
|
const specificity = rule.specificity || '';
|
|
|
|
let propsHtml = '';
|
|
const props = rule.properties || [];
|
|
for (const prop of props) {
|
|
const overridden = prop.overridden ? ' overridden' : '';
|
|
const nameHtml = escapeHtml(prop.name);
|
|
const valText = escapeHtml(prop.value || '');
|
|
const truncatedVal = valText.length > 30 ? valText.slice(0, 30) + '...' : valText;
|
|
const priority = prop.priority === 'important' ? ' <span class="inspector-important">!important</span>' : '';
|
|
propsHtml += `<div class="inspector-prop${overridden}"><span class="inspector-prop-name">${nameHtml}</span>: <span class="inspector-prop-value" title="${valText}">${truncatedVal}</span>${priority};</div>`;
|
|
}
|
|
|
|
return `
|
|
<div class="inspector-rule" role="treeitem">
|
|
<div class="inspector-rule-header">
|
|
<span class="inspector-selector" title="${selectorText}">${truncatedSelector}</span>
|
|
${specificity ? `<span class="inspector-specificity">${escapeHtml(specificity)}</span>` : ''}
|
|
</div>
|
|
<div class="inspector-rule-props">${propsHtml}</div>
|
|
${sourceDisplay ? `<div class="inspector-rule-source">${escapeHtml(sourceDisplay)}</div>` : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ─── Computed Styles Rendering ──────────────────────────────────
|
|
|
|
function renderComputedStyles(data) {
|
|
const styles = data.computedStyles || data.basicData?.computedStyles || {};
|
|
const keys = Object.keys(styles);
|
|
|
|
if (keys.length === 0) {
|
|
inspectorComputed.innerHTML = '<div class="inspector-no-data">No computed styles</div>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
for (const key of keys) {
|
|
const val = styles[key];
|
|
if (!val || val === 'none' || val === 'normal' || val === 'auto' || val === '0px' || val === 'rgba(0, 0, 0, 0)') continue;
|
|
html += `<div class="inspector-computed-row"><span class="inspector-prop-name">${escapeHtml(key)}</span>: <span class="inspector-prop-value">${escapeHtml(val)}</span></div>`;
|
|
}
|
|
|
|
if (!html) {
|
|
html = '<div class="inspector-no-data">All values are defaults</div>';
|
|
}
|
|
|
|
inspectorComputed.innerHTML = html;
|
|
}
|
|
|
|
// ─── Quick Edit ─────────────────────────────────────────────────
|
|
|
|
function renderQuickEdit(data) {
|
|
const selector = data.selector;
|
|
if (!selector) { inspectorQuickedit.innerHTML = ''; return; }
|
|
|
|
// Show common editable properties with current values
|
|
const editableProps = ['color', 'background-color', 'font-size', 'padding', 'margin', 'border', 'display', 'opacity'];
|
|
const computed = data.computedStyles || data.basicData?.computedStyles || {};
|
|
|
|
let html = '<div class="inspector-quickedit-list">';
|
|
for (const prop of editableProps) {
|
|
const val = computed[prop] || '';
|
|
html += `
|
|
<div class="inspector-quickedit-row" data-prop="${escapeHtml(prop)}">
|
|
<span class="inspector-prop-name">${escapeHtml(prop)}</span>:
|
|
<span class="inspector-quickedit-value" data-selector="${escapeHtml(selector)}" data-prop="${escapeHtml(prop)}" tabindex="0" role="button" title="Click to edit">${escapeHtml(val || '(none)')}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
html += '</div>';
|
|
inspectorQuickedit.innerHTML = html;
|
|
|
|
// Bind click-to-edit
|
|
inspectorQuickedit.querySelectorAll('.inspector-quickedit-value').forEach(el => {
|
|
el.addEventListener('click', () => startQuickEdit(el));
|
|
el.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); startQuickEdit(el); }
|
|
});
|
|
});
|
|
}
|
|
|
|
function startQuickEdit(valueEl) {
|
|
if (valueEl.querySelector('input')) return; // already editing
|
|
|
|
const currentVal = valueEl.textContent === '(none)' ? '' : valueEl.textContent;
|
|
const prop = valueEl.dataset.prop;
|
|
const selector = valueEl.dataset.selector;
|
|
|
|
const input = document.createElement('input');
|
|
input.type = 'text';
|
|
input.className = 'inspector-quickedit-input';
|
|
input.value = currentVal;
|
|
valueEl.textContent = '';
|
|
valueEl.appendChild(input);
|
|
input.focus();
|
|
input.select();
|
|
|
|
function commit() {
|
|
const newVal = input.value.trim();
|
|
valueEl.textContent = newVal || '(none)';
|
|
if (newVal && newVal !== currentVal) {
|
|
chrome.runtime.sendMessage({
|
|
type: 'applyStyle',
|
|
selector,
|
|
property: prop,
|
|
value: newVal,
|
|
});
|
|
inspectorModifications.push({ property: prop, value: newVal, selector });
|
|
updateSendButton();
|
|
}
|
|
}
|
|
|
|
function cancel() {
|
|
valueEl.textContent = currentVal || '(none)';
|
|
}
|
|
|
|
input.addEventListener('blur', commit);
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
|
|
if (e.key === 'Escape') { e.preventDefault(); input.removeEventListener('blur', commit); cancel(); }
|
|
});
|
|
}
|
|
|
|
// ─── Send to Agent ──────────────────────────────────────────────
|
|
|
|
function updateSendButton() {
|
|
if (inspectorModifications.length > 0) {
|
|
inspectorSendBtn.textContent = 'Send to Code';
|
|
inspectorSendBtn.title = `${inspectorModifications.length} modification(s) to send`;
|
|
} else {
|
|
inspectorSendBtn.textContent = 'Send to Agent';
|
|
inspectorSendBtn.title = 'Send full inspector data';
|
|
}
|
|
}
|
|
|
|
inspectorSendBtn.addEventListener('click', async () => {
|
|
if (!inspectorData) return;
|
|
|
|
let message;
|
|
if (inspectorModifications.length > 0) {
|
|
// Format modification diff
|
|
const diffs = inspectorModifications.map(m =>
|
|
` ${m.property}: ${m.value} (selector: ${m.selector})`
|
|
).join('\n');
|
|
message = `CSS Inspector modifications:\n\nSelector: ${inspectorData.selector}\n\nChanges:\n${diffs}`;
|
|
|
|
// Include source file info if available
|
|
const rules = inspectorData.matchedRules || inspectorData.basicData?.matchedRules || [];
|
|
const sources = rules.filter(r => r.source && r.source !== 'inline').map(r => r.source);
|
|
if (sources.length > 0) {
|
|
message += `\n\nSource files:\n${[...new Set(sources)].map(s => ` ${s}`).join('\n')}`;
|
|
}
|
|
} else {
|
|
// Send full inspector data
|
|
message = `CSS Inspector data for: ${inspectorData.selector}\n\n${JSON.stringify(inspectorData, null, 2)}`;
|
|
}
|
|
|
|
// Inject into the running claude PTY so the user can ask claude to act
|
|
// on the inspector data. Replaces the old `sidebar-command` route which
|
|
// spawned a one-shot claude -p (sidebar-agent.ts is gone).
|
|
//
|
|
// Pre-scan via /pty-inject-scan before injection (D6, closes #1370).
|
|
// gstackScanForPTYInject is async; gstackInjectToTerminal stays sync.
|
|
const verdict = await window.gstackScanForPTYInject?.(message + '\n', 'inspector-send');
|
|
if (verdict?.verdict === 'BLOCK') {
|
|
console.warn('[gstack sidebar] Inspector send BLOCKED by /pty-inject-scan:', verdict.reasons);
|
|
return;
|
|
}
|
|
if (verdict?.verdict === 'WARN') {
|
|
const confirmed = window.confirm(
|
|
`Inspector send flagged as suspicious (${(verdict.reasons || []).join(', ')}). Inject anyway?`,
|
|
);
|
|
if (!confirmed) return;
|
|
}
|
|
const ok = window.gstackInjectToTerminal?.(message + '\n');
|
|
if (!ok) {
|
|
console.warn('[gstack sidebar] Inspector send needs an active Terminal session.');
|
|
}
|
|
});
|
|
|
|
// ─── Quick Action Helpers (toolbar buttons) ──────────────────────
|
|
|
|
/**
|
|
* "Cleanup" injects a prompt into the running claude PTY. claude takes the
|
|
* prompt, snapshots the page, hides ads/banners/popups, leaves article
|
|
* content. The user watches it happen in the Terminal pane.
|
|
*
|
|
* Replaced the old chat-queue path (sidebar-agent.ts spawning a one-shot
|
|
* claude -p) — we have a live REPL now, so route through that instead.
|
|
*/
|
|
async function runCleanup(...buttons) {
|
|
buttons.forEach(b => b?.classList.add('loading'));
|
|
const cleanupPrompt = [
|
|
'Clean up the active browser page for reading. Run:',
|
|
'$B cleanup --all',
|
|
'then $B snapshot -i, identify any remaining ads, cookie/consent banners,',
|
|
'newsletter popups, login walls, video autoplay, sidebar widgets, share',
|
|
'buttons, floating chat widgets, and hide each via $B eval. Keep the site',
|
|
'header/masthead, headline, article body, images, byline, and date. Also',
|
|
'unlock scrolling if the page is scroll-locked.',
|
|
].join('\n');
|
|
// Pre-scan via /pty-inject-scan before injection (D6, closes #1370).
|
|
// The cleanup prompt is a STATIC template (no page-derived content), so
|
|
// it will always PASS, but we still route it through the scan path so
|
|
// the invariant test in test/extension-pty-inject-invariant.test.ts
|
|
// confirms every call site goes through gstackScanForPTYInject first.
|
|
const verdict = await window.gstackScanForPTYInject?.(cleanupPrompt + '\n', 'cleanup-button');
|
|
if (verdict?.verdict === 'BLOCK') {
|
|
console.warn('[gstack sidebar] Cleanup BLOCKED by /pty-inject-scan:', verdict.reasons);
|
|
setTimeout(() => buttons.forEach(b => b?.classList.remove('loading')), 200);
|
|
return;
|
|
}
|
|
if (verdict?.verdict === 'WARN') {
|
|
const confirmed = window.confirm(
|
|
`Cleanup flagged as suspicious (${(verdict.reasons || []).join(', ')}). Inject anyway?`,
|
|
);
|
|
if (!confirmed) {
|
|
setTimeout(() => buttons.forEach(b => b?.classList.remove('loading')), 200);
|
|
return;
|
|
}
|
|
}
|
|
const sent = window.gstackInjectToTerminal?.(cleanupPrompt + '\n');
|
|
if (!sent) {
|
|
console.warn('[gstack sidebar] Cleanup needs an active Terminal session.');
|
|
}
|
|
setTimeout(() => buttons.forEach(b => b?.classList.remove('loading')), 1200);
|
|
}
|
|
|
|
async function runScreenshot(...buttons) {
|
|
if (!serverUrl || !serverToken) return;
|
|
buttons.forEach(b => b?.classList.add('loading'));
|
|
try {
|
|
const resp = await fetch(`${serverUrl}/command`, {
|
|
method: 'POST',
|
|
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ command: 'screenshot', args: [] }),
|
|
signal: AbortSignal.timeout(15000),
|
|
});
|
|
const text = await resp.text();
|
|
if (!resp.ok) {
|
|
console.warn('[gstack sidebar] Screenshot failed:', text);
|
|
} else {
|
|
console.log('[gstack sidebar] Screenshot:', text);
|
|
}
|
|
} catch (err) {
|
|
console.error('[gstack sidebar] Screenshot error:', err.message);
|
|
} finally {
|
|
buttons.forEach(b => b?.classList.remove('loading'));
|
|
}
|
|
}
|
|
|
|
// ─── Wire up all cleanup/screenshot buttons (inspector + chat toolbar) ──
|
|
|
|
const inspectorCleanupBtn = document.getElementById('inspector-cleanup-btn');
|
|
const inspectorScreenshotBtn = document.getElementById('inspector-screenshot-btn');
|
|
const chatCleanupBtn = document.getElementById('chat-cleanup-btn');
|
|
const chatScreenshotBtn = document.getElementById('chat-screenshot-btn');
|
|
|
|
if (inspectorCleanupBtn) inspectorCleanupBtn.addEventListener('click', () => runCleanup(inspectorCleanupBtn, chatCleanupBtn));
|
|
if (inspectorScreenshotBtn) inspectorScreenshotBtn.addEventListener('click', () => runScreenshot(inspectorScreenshotBtn, chatScreenshotBtn));
|
|
if (chatCleanupBtn) chatCleanupBtn.addEventListener('click', () => runCleanup(chatCleanupBtn, inspectorCleanupBtn));
|
|
if (chatScreenshotBtn) chatScreenshotBtn.addEventListener('click', () => runScreenshot(chatScreenshotBtn, inspectorScreenshotBtn));
|
|
|
|
// ─── Section Toggles ────────────────────────────────────────────
|
|
|
|
document.querySelectorAll('.inspector-section-toggle').forEach(toggle => {
|
|
toggle.addEventListener('click', () => {
|
|
const section = toggle.dataset.section;
|
|
const body = document.getElementById(`inspector-${section}`);
|
|
const isCollapsed = toggle.classList.contains('collapsed');
|
|
|
|
toggle.classList.toggle('collapsed', !isCollapsed);
|
|
toggle.setAttribute('aria-expanded', isCollapsed);
|
|
toggle.querySelector('.inspector-toggle-arrow').innerHTML = isCollapsed ? '▼' : '▶';
|
|
body.classList.toggle('collapsed', !isCollapsed);
|
|
});
|
|
});
|
|
|
|
// ─── Inspector SSE ──────────────────────────────────────────────
|
|
|
|
async function connectInspectorSSE() {
|
|
if (!serverUrl || !serverToken) return;
|
|
if (inspectorSSE) { inspectorSSE.close(); inspectorSSE = null; }
|
|
|
|
// Same session-cookie pattern as connectSSE. ?token= is gone (see N1
|
|
// in the v1.6.0.0 security wave plan).
|
|
await ensureSseSessionCookie();
|
|
const url = `${serverUrl}/inspector/events?_=${Date.now()}`;
|
|
|
|
try {
|
|
inspectorSSE = new EventSource(url, { withCredentials: true });
|
|
|
|
inspectorSSE.addEventListener('inspectResult', (e) => {
|
|
try {
|
|
const data = JSON.parse(e.data);
|
|
inspectorShowData(data);
|
|
} catch (err) {
|
|
console.error('[gstack sidebar] Failed to parse inspectResult:', err.message);
|
|
}
|
|
});
|
|
|
|
inspectorSSE.addEventListener('error', () => {
|
|
// SSE connection failed — inspector works without it (basic mode)
|
|
if (inspectorSSE) { inspectorSSE.close(); inspectorSSE = null; }
|
|
});
|
|
} catch (err) {
|
|
console.debug('[gstack sidebar] Inspector SSE not available:', err.message);
|
|
}
|
|
}
|
|
|
|
// ─── Server Discovery ───────────────────────────────────────────
|
|
|
|
function setActionButtonsEnabled(enabled) {
|
|
const btns = document.querySelectorAll('.quick-action-btn, .inspector-action-btn');
|
|
btns.forEach(btn => {
|
|
btn.disabled = !enabled;
|
|
btn.classList.toggle('disabled', !enabled);
|
|
});
|
|
}
|
|
|
|
function updateConnection(url, token) {
|
|
const wasConnected = !!serverUrl;
|
|
serverUrl = url;
|
|
serverToken = token || null;
|
|
// Expose for sidepanel-terminal.js (PTY surface). The terminal pane needs
|
|
// the bootstrap token to POST /pty-session and the port to derive the WS
|
|
// URL. We never expose the PTY token — it lives in an HttpOnly cookie.
|
|
if (url) {
|
|
try { window.gstackServerPort = parseInt(new URL(url).port, 10); } catch {}
|
|
window.gstackAuthToken = token || null;
|
|
} else {
|
|
window.gstackServerPort = null;
|
|
window.gstackAuthToken = null;
|
|
}
|
|
if (url) {
|
|
document.getElementById('footer-dot').className = 'dot connected';
|
|
const port = new URL(url).port;
|
|
document.getElementById('footer-port').textContent = `:${port}`;
|
|
setConnState('connected');
|
|
setActionButtonsEnabled(true);
|
|
// Tell the active tab's content script the sidebar is open — this hides
|
|
// the welcome page arrow hint. Only fires on actual sidebar connection.
|
|
chrome.runtime.sendMessage({ type: 'sidebarOpened' }).catch(() => {});
|
|
connectSSE();
|
|
connectInspectorSSE();
|
|
} else {
|
|
document.getElementById('footer-dot').className = 'dot';
|
|
document.getElementById('footer-port').textContent = '';
|
|
setActionButtonsEnabled(false);
|
|
if (wasConnected) startReconnect();
|
|
}
|
|
}
|
|
|
|
// ─── Port Configuration ─────────────────────────────────────────
|
|
|
|
const portLabel = document.getElementById('footer-port');
|
|
const portInput = document.getElementById('port-input');
|
|
|
|
portLabel.addEventListener('click', () => {
|
|
portLabel.style.display = 'none';
|
|
portInput.style.display = '';
|
|
chrome.runtime.sendMessage({ type: 'getPort' }, (resp) => {
|
|
portInput.value = resp?.port || '';
|
|
portInput.focus();
|
|
portInput.select();
|
|
});
|
|
});
|
|
|
|
function savePort() {
|
|
const port = parseInt(portInput.value, 10);
|
|
if (port > 0 && port < 65536) {
|
|
chrome.runtime.sendMessage({ type: 'setPort', port });
|
|
}
|
|
portInput.style.display = 'none';
|
|
portLabel.style.display = '';
|
|
}
|
|
portInput.addEventListener('blur', savePort);
|
|
portInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') savePort();
|
|
if (e.key === 'Escape') { portInput.style.display = 'none'; portLabel.style.display = ''; }
|
|
});
|
|
|
|
// ─── Reconnect / Copy Buttons ────────────────────────────────────
|
|
|
|
document.getElementById('conn-reconnect').addEventListener('click', () => {
|
|
reconnectAttempts = 0;
|
|
startReconnect();
|
|
});
|
|
|
|
document.getElementById('conn-copy').addEventListener('click', () => {
|
|
navigator.clipboard.writeText('/open-gstack-browser').then(() => {
|
|
const btn = document.getElementById('conn-copy');
|
|
btn.textContent = 'copied!';
|
|
setTimeout(() => { btn.textContent = '/open-gstack-browser'; }, 2000);
|
|
});
|
|
});
|
|
|
|
// Try to connect immediately, retry every 2s until connected.
|
|
// Show exactly what's happening at each step so the user is never
|
|
// staring at a blank "Connecting..." with no info.
|
|
let connectAttempts = 0;
|
|
function setLoadingStatus(msg, debug) {
|
|
// The status line lives inside the Terminal bootstrap card now —
|
|
// sidepanel-terminal.js owns it. We only update the debug pre block,
|
|
// and trust the terminal pane to surface the human-readable status.
|
|
const dbg = document.getElementById('loading-debug');
|
|
if (dbg && debug !== undefined) dbg.textContent = debug;
|
|
}
|
|
|
|
async function tryConnect() {
|
|
connectAttempts++;
|
|
setLoadingStatus(
|
|
`Looking for browse server... (attempt ${connectAttempts})`,
|
|
`Asking background.js for server port...`
|
|
);
|
|
|
|
// Step 1: Ask background for the port
|
|
const resp = await new Promise(resolve => {
|
|
chrome.runtime.sendMessage({ type: 'getPort' }, (r) => {
|
|
if (chrome.runtime.lastError) {
|
|
resolve({ error: chrome.runtime.lastError.message });
|
|
} else {
|
|
resolve(r || {});
|
|
}
|
|
});
|
|
});
|
|
|
|
if (resp.error) {
|
|
setLoadingStatus(
|
|
`Extension error (attempt ${connectAttempts})`,
|
|
`chrome.runtime.sendMessage failed:\n${resp.error}`
|
|
);
|
|
setTimeout(tryConnect, 2000);
|
|
return;
|
|
}
|
|
|
|
const port = resp.port || 34567;
|
|
|
|
// Step 2: If background says connected + has token, use that
|
|
if (resp.port && resp.connected && resp.token) {
|
|
setLoadingStatus(
|
|
`Server found on port ${port}, connecting...`,
|
|
`token: yes\nStarting SSE + chat polling...`
|
|
);
|
|
updateConnection(`http://127.0.0.1:${port}`, resp.token);
|
|
return;
|
|
}
|
|
|
|
// Step 3: Background not connected yet. Try hitting /health directly.
|
|
// This bypasses the background.js health poll timing gap.
|
|
setLoadingStatus(
|
|
`Checking server directly... (attempt ${connectAttempts})`,
|
|
`port: ${port}\nbackground connected: ${resp.connected || false}\nTrying GET http://127.0.0.1:${port}/health ...`
|
|
);
|
|
|
|
try {
|
|
const healthResp = await fetch(`http://127.0.0.1:${port}/health`, {
|
|
signal: AbortSignal.timeout(2000)
|
|
});
|
|
if (healthResp.ok) {
|
|
const data = await healthResp.json();
|
|
if (data.status === 'healthy' && data.token) {
|
|
setLoadingStatus(
|
|
`Server healthy on port ${port}, connecting...`,
|
|
`token: yes (from /health)\nStarting SSE + activity feed...`
|
|
);
|
|
updateConnection(`http://127.0.0.1:${port}`, data.token);
|
|
// The SEC shield used to drive off /health.security via the chat
|
|
// path's classifier; with the chat path ripped, the indicator is
|
|
// not driven yet. Leaving the shield element hidden by default.
|
|
return;
|
|
}
|
|
setLoadingStatus(
|
|
`Server responded but not healthy (attempt ${connectAttempts})`,
|
|
`status: ${data.status}\ntoken: ${data.token ? 'yes' : 'no'}`
|
|
);
|
|
} else {
|
|
setLoadingStatus(
|
|
`Server returned ${healthResp.status} (attempt ${connectAttempts})`,
|
|
`GET /health → ${healthResp.status} ${healthResp.statusText}`
|
|
);
|
|
}
|
|
} catch (e) {
|
|
setLoadingStatus(
|
|
`Server not reachable on port ${port} (attempt ${connectAttempts})`,
|
|
`GET /health failed: ${e.message}\n\nThe browse server may still be starting.\nRun /open-gstack-browser in Claude Code.`
|
|
);
|
|
}
|
|
|
|
setTimeout(tryConnect, 2000);
|
|
}
|
|
tryConnect();
|
|
|
|
// ─── Message Listener ───────────────────────────────────────────
|
|
|
|
chrome.runtime.onMessage.addListener((msg) => {
|
|
if (msg.type === 'health') {
|
|
if (msg.data) {
|
|
const url = `http://127.0.0.1:${msg.data.port || 34567}`;
|
|
// Request token via targeted sendResponse (not broadcast) to limit exposure
|
|
chrome.runtime.sendMessage({ type: 'getToken' }, (resp) => {
|
|
updateConnection(url, resp?.token || null);
|
|
});
|
|
} else {
|
|
updateConnection(null);
|
|
}
|
|
}
|
|
if (msg.type === 'refs') {
|
|
if (document.querySelector('.tab[data-tab="refs"].active')) {
|
|
fetchRefs();
|
|
}
|
|
}
|
|
if (msg.type === 'inspectResult') {
|
|
inspectorPickerActive = false;
|
|
inspectorPickBtn.classList.remove('active');
|
|
if (msg.data) {
|
|
inspectorShowData(msg.data);
|
|
} else {
|
|
inspectorShowError('Element not found, try picking again');
|
|
}
|
|
}
|
|
if (msg.type === 'pickerCancelled') {
|
|
inspectorPickerActive = false;
|
|
inspectorPickBtn.classList.remove('active');
|
|
}
|
|
// browserTabState: full snapshot of all open tabs + the active one,
|
|
// pushed by background.js on chrome.tabs events. We forward it as a
|
|
// custom event so sidepanel-terminal.js can relay to terminal-agent.ts.
|
|
// Result: claude's <stateDir>/tabs.json + active-tab.json stay live.
|
|
if (msg.type === 'browserTabState') {
|
|
document.dispatchEvent(new CustomEvent('gstack:tab-state', {
|
|
detail: { active: msg.active, tabs: msg.tabs, reason: msg.reason },
|
|
}));
|
|
}
|
|
});
|