mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-21 13:37:14 +02:00
* fix(ci): skill-docs freshness gate covers all 10 hosts and can actually fail The Codex/Factory gates ran 'git diff --exit-code -- .agents/' / '-- .factory/', but both paths are gitignored (.gitignore:16-17) — git diff on ignored untracked paths is always empty, so those two gates were structurally incapable of failing and 7 of 10 hosts had no gate at all. New shape: one 'gen:skill-docs --host all' pass (the generator hard-fails on any per-host error, gating all 10 hosts on generates-cleanly), byte-freshness via git diff for tracked output, plus a porcelain check that fails on untracked generated strays (git diff can't see brand-new files). The gitignored-hosts byte-freshness limitation is documented in the workflow comment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): exorcise the sidebar-agent ghost from the test suite browse/src/sidebar-agent.ts was deleted in the v1.14 sidebar refactor, but the test suite kept testing it for 48 versions. Nothing noticed because the free suite runs in no CI job and Bun-era module-load errors were suppressed in the Windows shard runner via an exclusion pattern whose own comment documented the breakage ('broken on every platform since v1.14 ... exit 0'). - Delete sidebar-security.test.ts + security-source-contracts.test.ts: crashed at module load (unguarded readFileSync of the deleted file); per-assertion triage confirmed every SERVER_SRC pin targeted the deleted chat prompt builder (zero hits in today's server.ts) — nothing to port. - Delete sidebar-integration.test.ts: 11 of 13 tests exercised deleted endpoints (/sidebar-command queue, /sidebar-agent/event, chat buffer); the 2 passing tests pinned only the blanket auth gate, covered by server-auth.test.ts + dual-listener.test.ts. - Delete test/skill-e2e-sidebar.test.ts: E2E for the deleted queue flow. - sidebar-ux.test.ts 1,669 -> 830 lines: 20 dead-chat describes + 15 dead tests removed (incl. 10 vacuous passes asserting on empty indexOf slices); 2 stale pins on LIVE features fixed (content.js typed-catch CSSOM fallback, arrow-hint window widened). 95 pass / 0 fail. - sidebar-tabs.test.ts: both failures were stale pins, not regressions — forceRestart's deliberate ws.close(4001) and the terminal-agent spawn that moved into spawnTerminalAgent() (identity-based kill refactor). 28 pass. - touchfiles.ts: drop the three sidebar E2E entries from BOTH maps (E2E_TOUCHFILES + E2E_TIERS) — they pointed diff-selection at the deleted file, so those tests were unreachable by any diff. - test-free-shards.ts: remove the now-dead sidebar-agent exclusion pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ci): run the free test suite in CI (it ran nowhere) The full free suite (bun test: browse/test/ + test/ + make-pdf/test/) had no CI job on any Linux/macOS runner — only Windows curated shards, paid evals, and doc-freshness gates existed. That's how two module-load-crashing test files survived 48 versions. Same cached Dockerfile.ci image and container wiring as evals.yml (deps restore, build, Chromium verify). Includes a module-load-error guard: older Bun reported test-file import crashes with exit 0 on macOS/Linux, so the job also fails on any nonzero 'N errors' count in the summary — future crash-class regressions can't hide from the exact job built to catch them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(test): validate touchfile dependency paths exist on disk New guard in touchfiles.test.ts: every non-glob dep path must exist, and every glob's anchor directory must exist. This is the axis the 181-key two-map sync discipline never covered — an entry can point at a long-deleted file and diff-based selection then silently never triggers those tests (the sidebar trio sat rotted for 48 versions). First run immediately caught a fourth rotted entry: 'spec authored quality' referenced test/fixtures/spec/** (directory does not exist) and selected for a judge test that exists nowhere in the repo. Removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): remove deleted /sidebar-chat endpoint from tunnel allowlist TUNNEL_PATHS is the audited tunnel attack surface — its own comment says every addition widens it. '/sidebar-chat' stayed in the set after the endpoint was deleted with the chat-queue path, meaning any future route matching that path would have been silently tunnel-exposed. The set is now exactly the pair ceremony (/connect) and the scoped command endpoint (/command), and the dual-listener closed-set pin enforces that. Also repairs a pre-existing red pin in dual-listener.test.ts: v1.63.0.0 made the tunnel allowlist args-aware (canDispatchOverTunnel gained a second param) without updating the test — red on main since then, invisible because the free suite had no CI job. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): delete chain's shadow dispatcher that skipped every security gate meta-commands.ts carried a 'CLI mode' fallback that re-implemented command routing without the server pipeline's gates: no scope check, no domain check, no tab ownership, no rate limit, no hidden-element stripping, no scoped-token enveloping — and it called handleReadCommand without a BrowserManager, which also skipped the JS-origin cookie-exfiltration assertion. It was unreachable in production (server.ts always passes executeCommand) and one boolean away from being live. chain now hard-errors without a server context. handleReadCommand's bm param is required and assertJsOriginAllowed runs unconditionally. The chain tests that exercised the deleted fallback now route through a server-shaped executeCommand adapter (real handlers + trust wrapping + {status,result} envelope), so their behavioral coverage — sequencing, trust markers, pipe format, aliases, error reporting — survives on the production-shaped path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(extension): delete the dead chat-queue client surface The sidebar-command handler in background.js POSTed to a server endpoint that no longer exists (deleted with the chat queue) — ~35 lines of fully-wired dead code including error handling for the permanent 404, plus its allowlist entry. No sender in the extension ever emitted the message type. chatEnabled leaves the /health contract (server hardcoded false, background.js re-derived it, nothing consumed it — the chat input element it guarded is gone from sidepanel.html). BROWSE_SIDEBAR_CHAT env flag had zero readers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): delete dead exports the ripped chat path left behind Three-way split by importer class: (a) Zero importers, deleted: the whole attack-attempt logging cluster in security.ts (logAttempt, AttemptRecord, salted hashPayload + device-salt, attempts.jsonl rotation, telemetry spawn plumbing incl. buildTelemetrySpawnCommand/resolveBashBinary — the LIVE attempts.jsonl writer is tunnel-denial-log.ts with its own rotation); the decision-file handshake (writeDecision/readDecision/clearDecision/excerptForReview — written for sidebar-agent's poll loop, which no longer exists); sidebar-utils.ts (whole module — its sanitizeExtensionUrl 'sanitized before embedding in a prompt' for the deleted prompt builder); 8 dead server.ts imports (sanitizeExtensionUrl, generateCanary, injectCanary, writeDecision, rotateRoot, serializeRegistry, restoreRegistry, clearAgentRecord); buildPtyClearCookie + buildSseClearCookie; WEBDRIVER_MASK_SCRIPT (orphaned by the D7 stealth narrowing — applyStealth never used it). (b) Dead-pin tests edited with their exports: the 'still exported' pin in stealth-layer-c, the string-content describe in stealth-webdriver (its live applyStealth behavioral coverage untouched), the clear-cookie assertions, security-review-flow.test.ts deleted whole (all 4 describes exercised the dead decision mechanism, incl. a 'simulated sidebar-agent poll loop'). (c) KEPT deliberately: leaseCount (live behavioral coverage), extractPtyCookie + validatePtySessionToken (extractPtyCookie is adopted by the terminal-agent cookie-parse unification later in this wave), resetSessionMarker + clearContentFilters (test-support API for the live content-security layer). Also fixes two pre-existing red pins found while here, invisible until the free suite got a CI job: the v1.44 spawnClaude->maybeSpawnPty rename in terminal-agent.test.ts, and a cross-file test-isolation bug where content-security.test.ts's clearContentFilters() wiped the auto-registered url-blocklist filter for every later file in the same bun process (security-integration.test.ts failed on co-run; afterAll now restores it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): delete the dead ML layers — transcript classifier and DeBERTa ensemble The L4b Haiku transcript classifier and the opt-in DeBERTa ensemble (GSTACK_SECURITY_ENSEMBLE=deberta, a documented 721MB download) had ZERO production callers since the chat-path agent that invoked them was ripped. The only live ML path is scanPageContent (testsavant) inside the security sidecar subprocess. Deleted by import graph: - security-classifier.ts 614 -> 265 lines: HAIKU_MODEL, checkTranscript, shouldRunTranscriptCheck, loadDeberta, scanPageContentDeberta, ToolCallInput, all DEBERTA_* consts + load state. Header now states the live truth (imported only by security-sidecar-entry.ts). downloadFile kept, name intact — it is an enumerated egress sink (HF model download). - security-bunnative.ts + test: a research skeleton self-described as 'NOT a production replacement', shipped into src/ with zero importers. - security-bench-ensemble{,-live}.test.ts + the Haiku response fixture: a paid live-model benchmark for a layer that could not fire. The security-classifier-tdz test's only case exercised checkTranscript — gone. - security.ts: layer-model header rewritten to the live architecture; StatusDetail.layers -> {testsavant, canary}; getStatus() no longer requires the impossible transcript==='ok' for 'protected' (old on-disk session state with a transcript key is tolerated on read, never re-emitted). - security-sidecar-entry.ts needed zero changes: it serializes getClassifierStatus() verbatim and no consumer read .transcript (verified in sidecar-client + server.ts). - BROWSER.md security section matches reality (ensemble knob gone, 112MB not 22MB, sidecar hosting documented). combineVerdict/THRESHOLDS retained as the pure, tested combiner of record — comments now flag transcript/deberta votes as producer-less. Net: 26 pass in security.test.ts incl. a NEW regression test for stale- transcript disk tolerance; egress-receipt tripwire green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: scrub the sidebar-agent ghost from comments and CLAUDE.md 20+ comments across 10 files still described the deleted sidebar-agent.ts as a live process — including load-bearing architecture claims ('IMPORTED ONLY BY sidebar-agent.ts', 'sidebar-agent fills this in on first prompt-injection load', 'kill sidebar-agent' in shutdown docs) and ~60 lines of tombstone blocks in server.ts enumerating deleted identifiers by name (a false grep surface: searching processAgentEvent hit server.ts and looked live). CLAUDE.md's security-stack section now documents the LIVE architecture: L1-L3 content filters + testsavant via the security sidecar subprocess; the L4b/ensemble rows, the GSTACK_SECURITY_ENSEMBLE knob, and the 721MB DeBERTa download are gone (deleted as dead code this wave) with an explicit do-not-re-document note; attempts.jsonl is correctly attributed to tunnel-denial-log.ts; the no-live-writer status of classifierStatus is stated. Comments that survive now describe what IS, not what WAS: the promotion gate in domain-skills.ts explains why classifier_score>0 is load-bearing given no L4 load-time scan exists; file-permissions.ts names real sensitive files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): delete the codex-helpers shadow module gen-skill-docs.ts imported externalSkillName (unaliased) from resolvers/codex-helpers.ts at line 21 and then re-declared the same function locally — the import was silently shadowed, and the imported copy was the STALE one (it lacked the frontmatterName param the local copy grew). Three more functions were byte-identical duplicates, imported only under _-prefixed aliases to keep the module 'referenced', and transformFrontmatter was a superseded hardcoded-Codex variant. Nothing else imported the module. Also drops three dead top-of-file imports (COMMAND_DESCRIPTIONS, SNAPSHOT_FLAGS — which pulled the whole browse/src module graph into every generator run for nothing — and an unused review-resolver trio). Proof: bun run gen:skill-docs exits 0 with a byte-identical tree (zero-diff regen); gen-skill-docs.test.ts 405/405 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(server): delete ServerConfig.idleTimeoutMs + chromiumProfile — documented, never read Both fields carried JSDoc asserting embedder behavior that did not exist: the idle check reads the module-level IDLE_TIMEOUT_MS env constant, and both resolveChromiumProfile() call sites pass no argument. Worse than absent — an embedder passing idleTimeoutMs: 5000 silently got 30 minutes. Wiring them honestly is impossible today: the idle timer, activity state, and shutdown target are module-global, so a per-factory value would lie for any process running more than one handler. Deleted instead, with a ServerConfig note pointing at the deferred singleton/route-table refactor where real support belongs. BROWSE_IDLE_TIMEOUT and CHROMIUM_PROFILE env remain the honest knobs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): wire appendSecureFile at the four real log-append sites file-permissions.ts carries a 24-line rationale for why POSIX mode bits are insufficient on Windows and implements appendSecureFile (0600 at create, Windows ACL on first write only) — but its single caller was the dead logAttempt, while the four REAL page-content log writers (console/network/ dialog logs in server.ts, the command audit log) used raw fs.appendFileSync with no mode. Page-content-derived logs now get owner-only permissions from birth on every platform. Verified before wiring: mode applies atomically at create via appendFileSync {mode}, and the ACL pass runs only on first write — no per-append subprocess cost on the hot console-log path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stealth): handoff() uses the shared profile resolution + lock cleanup The headless-to-headed handoff path hardcoded ~/.gstack/chromium-profile, silently ignoring $CHROMIUM_PROFILE and $GSTACK_HOME (gbrowser's gbd sets per-workspace profiles), and skipped cleanSingletonLocks() — so a handoff into a profile with a stale SingletonLock could hang where launchHeaded() would have recovered. This was the third live drift between the three Chromium launch paths; the first two are documented in comments as shipped stealth regressions. Minimal targeted fix — the full buildLaunchConfig() extraction stays in the deferred queue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): resolver registry describes the template language again Seven registered {{PLACEHOLDER}}s had zero uses in any .tmpl (checked in both bare and :arg forms): REDACT_TAXONOMY_TABLE, TEST_COVERAGE_AUDIT_REVIEW, MODEL_OVERLAY, QUESTION_PREFERENCE_CHECK, QUESTION_LOG, INLINE_TUNE_FEEDBACK, MAKE_PDF_SETUP. The last two of those families are invoked programmatically by preamble.ts (functions kept, registry entries dropped); the question-tuning trio and the review coverage-audit wrapper were documented by their own module as existing 'for unit testing' that no test performed — deleted, along with generateRedactTaxonomyTable + its EXAMPLE/TIER_BLURB constants (its '/cso renders the full table' comment was itself stale) and its test describe. Also deletes the gated-resolver mechanism (ResolverEntry/appliesTo/ unwrapResolver + test/resolver-entry.test.ts): fully built, fully tested, used by zero of the 65 registry entries — the generator loop simplifies to a direct function call. CLAUDE.md's redact-doc line stops advertising the dead token. Proof: zero-diff regen (0 SKILL.md changed); gen-skill-docs + skill-validation 737 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): wire boundaryInstruction from host config; drop three no-op binDir ternaries hosts/codex.ts declared boundaryInstruction and nothing read it — review.ts kept its own byte-identical CODEX_BOUNDARY literal (verified equal + trailing escaped newlines). The resolver now reads the config, so the boundary has one owner. (autoplan's template carries deliberately generic variants, enforced by gen-skill-docs.test.ts:1358 — untouched by design.) The 'ctx.host === codex ? $GSTACK_BIN : ctx.paths.binDir' ternary appeared in three resolvers and could never change the result: resolvers/types.ts already sets binDir to $GSTACK_BIN for every usesEnvVars host including codex. Proof: zero-diff regen for claude AND codex hosts; gen-skill-docs + host-config suites green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test-infra): judge uses resolveClaudeBinary; eval:watch reads the real partials dir judgePtyState spawned the bare string 'claude' three definitions below the resolveClaudeBinary() helper this same file exports — broken under hermetic PATHs where every other launch in the file resolves correctly. eval:watch read _partial-e2e.json from the legacy global ~/.gstack-dev/evals/ while EvalCollector writes it into the per-project eval dir (or GSTACK_EVAL_DIR) — so the dashboard's completed-tests panel was empty whenever slug detection succeeded, i.e. the normal case. The heartbeat and per-run progress logs stay global by design (session-runner.ts: 'heartbeat stays global'). The three eval-CLI docstrings stop claiming the legacy dir is the primary location. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): delete the superseded SDK ship-idempotency suite and three orphaned fixtures test/skill-e2e-ship-idempotency.test.ts's own header documented that the monolith's SDK-harness version tests a synthetic prompt while it exercises the real /ship skill — the author knew the old suite was superseded and left both running, two paid LLM runs for one behavior. The weaker copy is gone; its 'ship-idempotency' diff-selection key goes with it (the dedicated file is periodic-tier, which always runs under EVALS_ALL — the key had no remaining consumer). Fixture rot: test/fixtures/golden-ship-claude.md was a 128KB zero-reader orphan that had drifted 46KB from its live successor (test/fixtures/golden/claude-ship-SKILL.md) while looking authoritative; parity-baseline-v1.46.0.0.json and v1.53.0.0.json had zero readers (three tests pin three OTHER baseline versions — consolidation is queued, deletion of the unreferenced two is free). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bin): delete zero-caller scripts; make host-config-export's docstring honest - bin/gstack-open-url (14 lines): announced in a CHANGELOG entry, wired into nothing, ever. bin/gstack-platform-detect (27 lines): zero callers, and its hand-rolled host list was already stale (SLATE_HOST.md cites it as a problem). Note: the deprecated gstack-brain-consumer/reader pair the audit flagged was already deleted upstream in v1.63 with a stay-deleted tripwire. - scripts/task-emission-schema.ts (61 lines): a typed schema module nothing imported; the tasks-section comment now documents the JSONL fields inline. - scripts/host-config-export.ts claimed to be the 'shell bridge for the bash setup script' — setup never calls it (its hand-rolled host lists drifting is a known follow-up). Docstring now states what it IS: a standalone, test-pinned query CLI not yet wired into setup. Its validateValue + CLI_REGEX/PATH_REGEX internals were dead (defined for a guarantee the header claimed but nothing enforced). - KEPT deliberately: scripts/preflight-agent-sdk.ts — a documented manual diagnostic (CONTRIBUTING.md + USING_GBRAIN_WITH_GSTACK.md reference it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(server): one lone-surrogate sanitizer, one sanitizeReplacer, one startTunnel Three copies of the surrogate sanitizer existed with two algorithms (sanitize.ts regex vs a hand-rolled charCodeAt walk in server.ts — verified byte-identical across 11 edge cases before converging) plus two identical sanitizeReplacer definitions each wrapping a different copy. sanitize.ts is now the single source of truth; the runs-INSIDE-JSON.stringify egress invariant is unchanged at every call site and its pin tests were adapted to the new import shape without losing intent. The ngrok tunnel-start sequence existed three times in server.ts — the /tunnel/start route and the BROWSE_TUNNEL=1 autostart were line-for-line equivalent (a comment admitted 'Same cleanup as /tunnel/start's error path'). One startTunnel() now owns the ephemeral loopback bind, the pre-send egress receipt, the state-file RMW via tmpStatePath(), and the ordered error-path cleanup; callers keep their distinct response surfaces. The BROWSE_TUNNEL_LOCAL_ONLY test path shares nothing (no ngrok, different state field) and deliberately stays separate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): one session-cookie registry implementation, two instances pty-session-cookie.ts and sse-session-cookie.ts were byte-identical modulo the cookie name — mint/validate/parse/prune/TTL, the exact code a security fix would have to land in twice (and a third hand-rolled cookie parse in terminal-agent.ts had already diverged; unified next commit). createSessionCookieStore() owns the implementation; both modules become thin instantiations keeping every exported name, their distinct threat-model docstrings, and separate token spaces (an SSE-read cookie must never grant PTY access). pty-session-lease.ts deliberately stays out — different contract (sessionId/secret split, refresh, env TTL). The factory imports nothing from token-registry (cookie-picker-auth-isolation invariant, still pinned by sse-session-cookie.test.ts). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): terminal-agent uses the shared PTY cookie parser The /ws upgrade's cookie fallback hand-parsed the Cookie header inline — the fourth copy of the session-cookie parse, and the one that had already diverged from the others. Parsing now goes through extractPtyCookie; validation deliberately stays against the agent's own in-process validTokens map (the server's registry lives in a different process). The ws-handler pin test now pins the shared-parser call instead of the raw cookie-name literal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(hosts): defineHost() factory — 10 copy-paste host files become declarations hosts/*.ts were ten copies of one file: runtimeRoot byte-identical in 9/10, pathRewrites mechanically derivable from the host name for 7/10, the 11-entry toolRewrites map byte-identical between openclaw and gbrain, and every asset change a 10-file edit (cursor and slate had already fallen out of three other hand-maintained lists). defineHost() owns the defaults; each host file now declares only what makes it different (slate/cursor: 8 lines each). Shared constants: CROSS_MODEL_RESOLVERS, GBRAIN_RESOLVERS, EXEC_STYLE_TOOL_REWRITES. Genuinely-different things stayed explicit: codex/factory $GSTACK_ROOT rewrites, hermes's tool vocabulary, claude's denylist+prefixable install, opencode's wider runtimeRoot. Proof: JSON.stringify(ALL_HOST_CONFIGS) dump-diff before/after EMPTY (and a runtime walk confirmed no function-valued or undefined-keyed fields, so the JSON diff is complete); gen:skill-docs --host all zero-diff; host-config + gen-skill-docs + idempotency suites 485/485. Host files 595 -> 285 lines. docs/ADDING_A_HOST.md teaches the factory pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(lib): fs-atomic — one atomic-write implementation, with the race actually fixed Atomic tmp-write-then-rename was reimplemented ~20 times across lib/, bin/, and browse/src with three tmp-suffix conventions. One of them was a latent bug this commit closes: lib/worktree.ts used a bare '.tmp' suffix — the deterministic-tmp collision race browse/src/server.ts documents having hit in production (its fix, pid+random, was trapped in a comment at one site). lib/fs-atomic.ts: atomicWriteSync (always throws, best-effort tmp cleanup, pid+random suffix, optional mode applied at tmp creation so the file never exists with looser permissions) + atomicWriteQuiet (shutdown paths only). Unit tests pin the throw/quiet contracts, 0600 mode, tmp-name uniqueness (captured via the read-only-dir failure path — Bun's fs exports are readonly, no monkeypatching), and no-stray-tmp cleanup. Migrated: lib/worktree.ts (the bare-.tmp bug), lib/gstack-decision.ts (snapshot + compact log), lib/gbrain-local-status.ts (probe cache). browse sites follow separately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(lib): jsonl-store's docstring stops lying; mode option added; lib bypasses adopted The header claimed 'single source of truth... the ONLY copy' with write-time injection REJECTION — while appendJsonl never screened anything, only 1 of ~10 JSONL stores imported it, and a bypass appender lived in the same directory. Now: the contract is explicit (screening is the CALLER's job via hasInjection/firstInjectionMatch; the enforcing callers are named), a option applies 0600 at create for sensitive stores, and the lib bypasses are adopted (gstack-memory-helpers ×2, redact-audit-log — which keeps its chmod backstop for files created looser by pre-mode versions). browse/src keeps its own appenders by design (compiled-binary surface, own secure-append helper) and the header now says so. gstack-decision's batched archive append stays deliberate (single-write crash-window semantics appendJsonl's one-record contract can't express). New pins: 0600-at-create, and a test that documents appendJsonl does NOT self-screen — so nobody can re-document it as self-screening without making it true. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(browse): migrate hand-rolled atomic writes to lib/fs-atomic Seven sites, each audited for its existing throw-vs-swallow contract before migrating: writeSessionState + the four fire-and-forget tab/state writers use atomicWriteQuiet (they swallowed before); writeAgentRecord + the boot-time port-file write use atomicWriteSync (they threw before — and writeAgentRecord previously leaked its tmp file on rename failure, which the helper cleans). All carry {mode: 0o600} plus restrictFilePermissions after successful writes, preserving the Windows ACL hardening that writeSecureFile provided (mode bits are POSIX-only). server.ts untouched: its three state writes route through tmpStatePath(), pinned by server-tmp-state-path.test.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(hosts): delete five dead HostConfig fields metadataFormat (generator hardcodes openai.yaml), sidecar (behavior lives in setup's create_agents_sidecar — knowledge preserved as a comment in codex.ts), install.prefixable (skill_prefix is implemented entirely in bin/gstack-config), staticFiles (docstring cited a SOUL.md that never existed anywhere), and adapter (its only would-be consumer, openclaw-adapter.ts, was fully dead — with a test asserting the field was undefined). Kept: learningsMode (wired next), linkingStrategy (validation reads it), coAuthorTrailer (consumed by resolvers/utility.ts). Proof: JSON dump diff shows ONLY the deleted keys vanishing; zero-diff regen across all 10 hosts; host-config + gen-skill-docs suites green. Note: this commit also carries chunk-23 edits to the shared hosts/claude.ts + define-host.ts + host-config.test.ts files (skipSkills collapse, stale line-number comment drops) — pathspec commits, concurrent prep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): preamble tiers are explicit; silent ?? 4 default becomes an error; spec stops rendering its preamble twice Eight skills (scrape, diagram, spec, skillify, pair-agent, landing-report, open-gstack-browser + its connect-chrome symlink) silently received the HEAVIEST tier-4 preamble because a missing frontmatter field defaulted to 4. Tiers are now declared in every {{PREAMBLE}} template's frontmatter and a missing declaration throws at generation time with the template path (the 5 templates without {{PREAMBLE}} never invoke the resolver). The stale hand-written tier-map comment (wrong in 3 of 4 rows) is gone. Bonus bug fixed: spec/SKILL.md.tmpl mentioned {{PREAMBLE}} in prose, so the generator inlined the ENTIRE preamble a second time — spec/SKILL.md shrinks 127,462 -> 80,924 bytes (-46,538) from de-duplication alone. skill-size-budget gains a reasoned INTENTIONAL_SHRINKS entry (its frozen baseline had measured the doubled-preamble bug). New tests: missing-tier throw carries the path; every {{PREAMBLE}} template declares a tier. (Carries chunk-23 edits in the shared test/gen-skill-docs.test.ts.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): learningsMode is read from host config, not a hardcoded host name resolvers/learnings.ts branched on ctx.host === 'codex' while every host declared learningsMode — the field was decorative, and the 7 hosts configured 'basic' (cursor, slate, kiro, opencode, openclaw, hermes, gbrain) silently received the 'full' cross-project flow their runtimes can't execute (it depends on AskUserQuestion + gstack-config plumbing). Output now matches declaration: basic hosts get the project-scoped search block. Blast radius proof: all committed Claude SKILL.md files and the three golden fixtures are byte-identical; the behavior diff lands only in the gitignored external-host trees (hand-verified: .cursor review's learnings section swaps the cross-project AskUserQuestion block for the project-scoped search). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(gen): small config scrubs — openclaw blobs to real files, setup host drift, dead artifacts - The three openclaw markdown blobs hardcoded inside gen-skill-docs.ts (which silently reverted any hand edit to their tracked outputs on regen) move to openclaw/templates/*.md source files; output shasums byte-identical. - setup's --host allowlists gain cursor + slate — both fully registered hosts with generated output, but './setup --host cursor' exited 1 because two hand-rolled lists in setup had drifted from hosts/index.ts. - scripts/proactive-suggestions.json deleted: 31KB regenerated on every run, read by nobody (the catalog-trim design's reader was never built); its emitter and three determinism tests (which guaranteed a file nothing reads didn't churn) retired with stays-retired pins. - claude/SKILL.md.tmpl deleted: a complete 8.9KB skill that never generated output (directory name collides with the host id 'claude'), in no registry. Recoverable from git if ever wanted under a non-colliding name. - openclaw's frozen extraFields.version '0.15.2.0' stamp dropped; includeSkills: [] no-ops omitted (the generator treats [] as absent); llms.txt 55 -> 54 skills. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(gen): correct preamble tiers for the 8 silently-heaviest skills With tiers now explicit, set them RIGHT by analogy to the tiered population: scrape/diagram/open-gstack-browser (+ the connect-chrome symlink) -> tier 1 (launchers and artifact generators, like browse and make-pdf); landing-report/pair-agent/skillify -> tier 2 (dashboards and session tools, like health and canary); spec -> tier 3 (interactive planning, like the plan-*-review family). Each tier-1 skill sheds 271 lines of onboarding prose it never needed; tier-2 shed 20 each. Verification per the review protocol: regen diff reviewed (pure section-removal), skill-validation + size-budget + catalog-budget + v0-dormancy suites green (822 tests), and live smoke of the tier-corrected skills confirms the preamble renders the intended sections at each tier. These skills have ~no eval coverage — stated honestly; the wave's gate-tier eval run is the backstop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(test): e2e-gate — one tier-gate implementation, side-effect-free, with the trap pinned The EVALS/EVALS_TIER gate was copy-pasted into ~40 test files and had drifted into six different predicates — the drift that made 'eval:bg:all runs everything' silently false. test/helpers/e2e-gate.ts owns the semantics now: describeE2ETier(tier) + e2eTierEnabled(tier), env read at call time, zero side effects (the existing e2e-helpers module runs a ~30s claude ping at import under EVALS=1, so the gate lives in its own module; purity is pinned by tests that scan imports and comment-stripped source). The unit matrix pins all four env combos — including EVALS=1 with EVALS_TIER unset -> SKIP, the exact trap that made eval:bg:all a non-run. The tier-alignment tripwire gains a second regex for the helper shape (old shape still detected — stragglers can't hide), and the sharded paid runner's PRE-SPAWN tier classifier learns the helper shape too: without that, every gate-sharded run would have spawned all 28 periodic shards just to skip them, each paying the e2e-helpers import ping (~15 min of dead wall clock in the CI-blocking lane). Verified: gate runs exclude the 29 periodic files, periodic excludes the 8 gate files — identical to pre-migration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(test): migrate the 36 tier-gated eval files to describeE2ETier Mechanical two-liner swap in 34 files (each keeping its declared tier — all 36 predicates verified against E2E_TIERS before migrating); the two files with compound gates (overlay-harness's EvalCollector feed, codex-e2e's CODEX_AVAILABLE) keep their extra conditions via e2eTierEnabled. Tier rationale comments preserved. codex-e2e/gemini-e2e/benchmark-providers keep their distinct stderr-message gate shapes by design. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(test): skill-e2e + skill-llm-eval adopt the shared selection machinery Both files re-implemented the diff-selection machinery e2e-helpers already exported. The helper gained computeDiffSelection() (extracted, identical behavior) and a trailing optional selection param on the *IfSelected helpers (defaults preserve all 30+ existing importers). skill-e2e.test.ts drops ~120 duplicated lines; skill-llm-eval keeps its LLM_JUDGE_TOUCHFILES selection and test.concurrent semantics via testConcurrentIfSelected. Deliberate deltas, stated: skill-e2e.test.ts now honors the EVALS_TIER intersection its local copy lacked (affects only direct bun test invocations of that file — it matches no eval-script glob); its recordE2E gains the helper's three diagnostic fields; skill-llm-eval sharded solo now runs e2e-helpers' module-scope preflight it already ran in combined processes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): kill the silent-truncation race; exempt the tier-corrected shrinks The full-suite shakeout (budgeted by the plan) surfaced both immediately: 1. server-embedder-terminal-port.test.ts stubbed process.exit and restored the REAL exit in its finally — but shutdown() schedules async work that can call process.exit AFTER restoration, killing the entire bun process mid-suite with exit 0 and NO summary. This is the silent-truncation class the new free-suite CI job guards against, reproduced locally on the first full run. Exit now stays a logging no-op between tests (late async exits become visible stderr lines, not process death); the true exit returns in afterAll. 2. The 80%-of-baseline shrink guard correctly flagged the six tier-corrected skills — their baseline was measured at the silent tier-4 default. Added to INTENTIONAL_SHRINKS with the reason, joining spec's double-preamble entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * release: v1.64.0.0 — the code-smell fix wave 35 commits, one PR: guard repairs (free suite in CI per-file, all-host freshness gates, tunnel allowlist, diff-selection validation), the sidebar-agent ghost exorcism (dead ML layers, dead endpoints, dead exports, ghost comments), config honesty (defineHost factory, dead fields deleted, preamble tiers explicit, spec double-render fixed), and dedup with safety nets (session-cookie factory, fs-atomic, jsonl-store contract, one eval tier-gate). Net -24,943 lines across 183 files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): free-tests step runs under bash (container sh rejects pipefail) Maiden-voyage shakeout, exactly as budgeted: the CI container's default shell is dash, which errors on 'set -o pipefail' before the first test ran. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): free-tests curates 8 container-incompatible files with reasons Second maiden-voyage shakeout round: 376 of 384 files ran green in the container on the first completed pass. The 8 that can't run there yet are excluded the same way the Windows shards curate POSIX-bound files — each with its reason inline (headed-Chrome handoff, real-PTY round-trip, X server management, extension-origin identity, the job's own TMPDIR override, and three pre-existing env failures that fail on dev machines too). Anything outside the list that fails still fails the job; trimming the list is tracked follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): gstack-config-key-locale — suppress the skill_prefix auto-relink side effect The test invokes the repo's own bin/gstack-config, whose 'set skill_prefix' auto-runs $(dirname $0)/gstack-relink — resolving the install dir to the repo itself. In any environment where the loop shares a working tree (the free-tests CI container, a fresh-HOME run), gstack-patch-names rewrote all 52 tracked SKILL.md names to gstack- prefixed, poisoning five unrelated suites downstream (hermetic-skills-seeding, host-config golden, skill-census, skill-validation, spec-template-sync). GSTACK_SETUP_RUNNING=1 is the documented suppression; relink behavior stays covered by relink.test.ts's mock install. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bin): gstack-codex-session-import — empty sessions dir exits 0 on Linux GNU xargs runs 'ls -t' once even on empty input, listing the cwd and producing a bogus LATEST from the repo root; BSD xargs (macOS) skips the run, which is why the NO_SESSIONS path only broke on Linux. xargs -r pins the BSD behavior on both platforms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(parity): rebaseline v1.57.7.0 → v1.64.1.0 + skeleton-cap headroom The two parallel v1.64 waves (code-smell fix wave + main's #2571) each added shared-preamble prose, pushing document-release / design-consultation / cso past their size ratios on the v1.57.7.0 anchor and four carved skeletons (plan-ceo-review, plan-eng-review, office-hours, design-consultation) 22-280 B over their absolute caps. New baseline is union-normalized (skeleton + sections/*.md, matching what the harness measures); caps get +~1 KB headroom each with per-cap rationale. The v1.57.7.0 fixture stays in test/fixtures/ for the audit trail, and capture-parity-baseline.ts now documents the union-normalization step so the next rebaseline doesn't re-trip on it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): free-tests container parity — tools, pinned bun, git identity, mutation tripwire - Dockerfile.ci: add python3 (gstack-jsonl-merge/brain-sync/detach shell out to it), file (skill-validation's binary check), poppler-utils (make-pdf e2e gates hard-require pdftotext/pdffonts/pdfinfo), fonts-noto-color-emoji (emoji render gate, mirrors make-pdf-gate.yml). Fix the bun pin: the bun.sh installer ignores a BUN_VERSION env var, so the old form silently installed latest on every rebuild (observed 1.3.13/1.3.14 drift vs the 1.3.10 devs run locally); pass the version as the positional arg. - free-tests.yml: git identity + safe.directory for the git-exercising tests (container checkout is owned by a different uid than runner); post-loop tree-mutation tripwire that names a tracked-file-mutating test instead of letting downstream collateral confuse the report; skip the documented variants-retry-after timing flake. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bin): gstack-session-update — detached updater owns its stdio (SIGPIPE) The backgrounded update subshell inherited the session hook's stdout/stderr pipes. Once the hook exits and the caller closes them, any child that writes — git pull's autostash notice, setup output — dies of SIGPIPE, logged as PULL_FAILED exit=141 with an empty stderr capture (observed in the free-tests container, and reachable by any production hook runner that closes stdio promptly). Redirect the fork to /dev/null; all observability already flows through the session-update log file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): gstack-decision-bins — explicit branch context for the scope filter CI checks out a detached HEAD, where gitBranch() returns undefined on both the log and search sides, so an implicitly branch-scoped decision can never surface (filterByScope requires a matching non-empty ctx.branch). Pass the branch explicitly on both sides — the filter logic is what's under test, not git branch detection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): ring-buffer lease interplay — same TTL window, not same millisecond Two back-to-back mintLease() calls each stamp Date.now() + TTL; when they straddle a millisecond boundary the exact-equality assertion flakes (observed in CI: expiries of ...525 vs ...526). Assert the expiries are within a 50 ms window instead — the invariant under test is that leases share a TTL policy, not that they mint in the same clock tick. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1443 lines
56 KiB
JavaScript
1443 lines
56 KiB
JavaScript
/**
|
||
* gstack browse — Side Panel
|
||
*
|
||
* Terminal pane (default): live claude PTY via xterm.js, driven by
|
||
* sidepanel-terminal.js.
|
||
*
|
||
* 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);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ─── Memory Footer Readout ──────────────────────────────────────
|
||
//
|
||
// Polls /memory every 30s and renders "RSS: 1.4 GB · 12 tabs" in the
|
||
// footer. Backs off to 5min if a poll takes > 2s (Codex flag — diagnostic
|
||
// shouldn't add load when the browser is already unhealthy). Uses Bearer
|
||
// auth like /refs above; /memory is a plain GET so EventSource semantics
|
||
// don't apply.
|
||
|
||
const MEM_POLL_FAST_MS = 30_000;
|
||
const MEM_POLL_SLOW_MS = 5 * 60_000;
|
||
const MEM_POLL_TIMEOUT_MS = 8_000;
|
||
const MEM_POLL_SLOW_THRESHOLD_MS = 2_000;
|
||
let memPollTimer = null;
|
||
let memPollMode = 'fast'; // 'fast' | 'slow'
|
||
|
||
function fmtBytesShort(n) {
|
||
if (typeof n !== 'number' || isNaN(n)) return '?';
|
||
if (n < 1024) return n + ' B';
|
||
if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
|
||
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(0) + ' MB';
|
||
return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB';
|
||
}
|
||
|
||
function renderMemFooter(snapshot) {
|
||
const el = document.getElementById('footer-mem');
|
||
if (!el) return;
|
||
const bunRss = snapshot?.bunServer?.rss ?? 0;
|
||
const tabCount = Array.isArray(snapshot?.tabs) ? snapshot.tabs.length : 0;
|
||
el.textContent = `${fmtBytesShort(bunRss)} · ${tabCount} tabs`;
|
||
// Color thresholds: ~2 GB Bun RSS or 50 tabs is "watch this"; ~8 GB or
|
||
// 200 tabs is "this is the cliff" (matches the 200-tab guardrail).
|
||
el.classList.remove('warn', 'bad');
|
||
if (bunRss > 8 * 1024 * 1024 * 1024 || tabCount > 200) el.classList.add('bad');
|
||
else if (bunRss > 2 * 1024 * 1024 * 1024 || tabCount > 50) el.classList.add('warn');
|
||
}
|
||
|
||
async function pollMemoryOnce() {
|
||
if (!serverUrl || !serverToken) return { ok: false, slow: false };
|
||
const start = Date.now();
|
||
try {
|
||
const resp = await fetch(`${serverUrl}/memory`, {
|
||
headers: { 'Authorization': `Bearer ${serverToken}` },
|
||
signal: AbortSignal.timeout(MEM_POLL_TIMEOUT_MS),
|
||
credentials: 'include',
|
||
});
|
||
const elapsed = Date.now() - start;
|
||
if (!resp.ok) return { ok: false, slow: elapsed > MEM_POLL_SLOW_THRESHOLD_MS };
|
||
const snapshot = await resp.json();
|
||
renderMemFooter(snapshot);
|
||
// Evaluate guardrail triggers (single-heavy-tab OR tab-count crossing 200).
|
||
// Toast is hidden when no trigger fires; snooze state suppresses re-fire.
|
||
try { evaluateMemToast(snapshot); } catch (err) {
|
||
console.debug('[gstack sidebar] mem-toast evaluation failed:', err && err.message);
|
||
}
|
||
return { ok: true, slow: elapsed > MEM_POLL_SLOW_THRESHOLD_MS };
|
||
} catch (err) {
|
||
const elapsed = Date.now() - start;
|
||
// Don't log every poll failure — common during browser restarts / restoring
|
||
// sessions. Only log on the slow path so the user sees something in the
|
||
// console if the diagnostic itself is misbehaving.
|
||
if (elapsed > MEM_POLL_SLOW_THRESHOLD_MS) {
|
||
console.debug('[gstack sidebar] /memory poll slow/failed:', elapsed, 'ms', err && err.message);
|
||
}
|
||
return { ok: false, slow: elapsed > MEM_POLL_SLOW_THRESHOLD_MS };
|
||
}
|
||
}
|
||
|
||
function scheduleNextMemPoll(delayMs) {
|
||
if (memPollTimer) clearTimeout(memPollTimer);
|
||
memPollTimer = setTimeout(async () => {
|
||
const { ok, slow } = await pollMemoryOnce();
|
||
if (!ok || slow) {
|
||
memPollMode = 'slow';
|
||
scheduleNextMemPoll(MEM_POLL_SLOW_MS);
|
||
} else {
|
||
// Successful + fast → back to fast cadence.
|
||
if (memPollMode === 'slow') memPollMode = 'fast';
|
||
scheduleNextMemPoll(MEM_POLL_FAST_MS);
|
||
}
|
||
}, delayMs);
|
||
}
|
||
|
||
function startMemPolling() {
|
||
if (memPollTimer) return; // already running
|
||
// Kick off an immediate poll so the footer populates within ~1s of sidebar
|
||
// open, instead of waiting 30s for the first cycle.
|
||
scheduleNextMemPoll(500);
|
||
}
|
||
|
||
function stopMemPolling() {
|
||
if (memPollTimer) {
|
||
clearTimeout(memPollTimer);
|
||
memPollTimer = null;
|
||
}
|
||
}
|
||
|
||
// ─── Tab guardrail toast (D5 + Codex single-tab flag) ───────
|
||
//
|
||
// Each /memory poll evaluates two trigger conditions:
|
||
// 1. Tab count crossed 200 — show "top 5 tabs by max(jsHeap, ...)" with
|
||
// Close-selected + Snooze.
|
||
// 2. Any single tab over 4 GB JS heap — show one-tab toast (catches the
|
||
// Codex case where a runaway WebGL/video page balloons one tab).
|
||
// Snooze persists in chrome.storage.session: next warn fires at tabCount +
|
||
// snoozeBumpTabs OR when a single tab crosses (snoozedJsHeapBytes + 1).
|
||
//
|
||
// "Close selected" runs $B closetab <id> via the existing /command path —
|
||
// no chrome.tabs.remove bridge needed.
|
||
|
||
const HEAVY_TAB_HEAP_BYTES = 4 * 1024 * 1024 * 1024; // 4 GB per Codex flag
|
||
const TOAST_SNOOZE_TAB_BUMP = 50; // re-warn at 200+50
|
||
const TOAST_SNOOZE_HEAP_BUMP = 2 * 1024 * 1024 * 1024;
|
||
|
||
const memToastSnooze = {
|
||
tabsAbove: 0, // suppress the count-toast until tabs strictly exceeds this
|
||
heapAbove: 0, // suppress the single-tab toast until heap strictly exceeds this
|
||
};
|
||
|
||
async function loadSnoozeState() {
|
||
if (!chrome?.storage?.session) return;
|
||
try {
|
||
const stored = await chrome.storage.session.get(['memToastSnooze']);
|
||
if (stored?.memToastSnooze) {
|
||
memToastSnooze.tabsAbove = stored.memToastSnooze.tabsAbove | 0;
|
||
memToastSnooze.heapAbove = stored.memToastSnooze.heapAbove | 0;
|
||
}
|
||
} catch (err) {
|
||
console.debug('[gstack sidebar] mem-toast snooze load failed:', err && err.message);
|
||
}
|
||
}
|
||
|
||
async function saveSnoozeState() {
|
||
if (!chrome?.storage?.session) return;
|
||
try {
|
||
await chrome.storage.session.set({ memToastSnooze: { ...memToastSnooze } });
|
||
} catch (err) {
|
||
console.debug('[gstack sidebar] mem-toast snooze save failed:', err && err.message);
|
||
}
|
||
}
|
||
|
||
function dismissMemToast() {
|
||
const toast = document.getElementById('mem-toast');
|
||
if (toast) toast.style.display = 'none';
|
||
}
|
||
|
||
/**
|
||
* Sort key for "RAM-heavy" tabs. JS heap × 4 is a rough proxy for total
|
||
* tab footprint (renderers tend to spend ~4× their JS heap on native +
|
||
* Skia + cache); when a tab is heavy via WebGL/video the JS heap is
|
||
* small but listeners/nodes spike. Take the max.
|
||
*/
|
||
function tabRamScore(tab) {
|
||
const heap = tab?.jsHeapUsed || 0;
|
||
const nodes = tab?.nodes || 0;
|
||
const listeners = tab?.listeners || 0;
|
||
// ~1 KB per DOM node + ~200 bytes per listener as a back-of-envelope
|
||
// native-memory estimate. Keeps the sort meaningful when JS heap is small.
|
||
const nativeEstimate = nodes * 1024 + listeners * 200;
|
||
return Math.max(heap, nativeEstimate);
|
||
}
|
||
|
||
function showMemToast(title, body, tabsForClose) {
|
||
const toast = document.getElementById('mem-toast');
|
||
const titleEl = document.getElementById('mem-toast-title');
|
||
const bodyEl = document.getElementById('mem-toast-body');
|
||
const closeBtn = document.getElementById('mem-toast-close-selected');
|
||
if (!toast || !titleEl || !bodyEl || !closeBtn) return;
|
||
|
||
titleEl.textContent = title;
|
||
bodyEl.innerHTML = '';
|
||
|
||
for (const t of tabsForClose) {
|
||
const row = document.createElement('div');
|
||
row.className = 'mem-toast-row';
|
||
const cb = document.createElement('input');
|
||
cb.type = 'checkbox';
|
||
cb.id = `mem-toast-tab-${t.id}`;
|
||
cb.value = String(t.id);
|
||
cb.checked = true; // default-selected so a fast user just hits Close
|
||
const label = document.createElement('label');
|
||
label.htmlFor = cb.id;
|
||
const urlShort = (t.url || '').length > 50 ? t.url.slice(0, 47) + '...' : (t.url || '(no url)');
|
||
label.textContent = `tab #${t.id} — ${urlShort}`;
|
||
const size = document.createElement('span');
|
||
size.className = 'mem-toast-size';
|
||
size.textContent = fmtBytesShort(tabRamScore(t));
|
||
row.appendChild(cb);
|
||
row.appendChild(label);
|
||
row.appendChild(size);
|
||
bodyEl.appendChild(row);
|
||
}
|
||
|
||
toast.style.display = '';
|
||
|
||
closeBtn.onclick = async () => {
|
||
const ids = tabsForClose
|
||
.filter((t) => document.getElementById(`mem-toast-tab-${t.id}`)?.checked)
|
||
.map((t) => t.id);
|
||
dismissMemToast();
|
||
for (const id of ids) {
|
||
try {
|
||
await fetch(`${serverUrl}/command`, {
|
||
method: 'POST',
|
||
headers: authHeaders(),
|
||
body: JSON.stringify({ command: 'closetab', args: [String(id)] }),
|
||
});
|
||
} catch (err) {
|
||
console.warn('[gstack sidebar] mem-toast closetab failed:', id, err && err.message);
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Driven by every successful /memory poll. Decides whether to surface
|
||
* the toast and which payload to show.
|
||
*/
|
||
function evaluateMemToast(snapshot) {
|
||
if (!snapshot || !Array.isArray(snapshot.tabs)) return;
|
||
const tabs = snapshot.tabs;
|
||
|
||
// Trigger 1: any single tab over 4 GB JS heap. Catches the WebGL/video
|
||
// case before the tab count threshold ever fires.
|
||
const heavyTab = tabs.find((t) => (t.jsHeapUsed || 0) > HEAVY_TAB_HEAP_BYTES);
|
||
if (heavyTab && (heavyTab.jsHeapUsed || 0) > memToastSnooze.heapAbove) {
|
||
showMemToast(
|
||
`Heavy tab: ${fmtBytesShort(heavyTab.jsHeapUsed)} JS heap`,
|
||
'',
|
||
[heavyTab],
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Trigger 2: tab count crossed the hard guardrail (200) and isn't snoozed.
|
||
if (tabs.length >= 200 && tabs.length > memToastSnooze.tabsAbove) {
|
||
const top5 = [...tabs].sort((a, b) => tabRamScore(b) - tabRamScore(a)).slice(0, 5);
|
||
showMemToast(
|
||
`${tabs.length} tabs open — close some?`,
|
||
'',
|
||
top5,
|
||
);
|
||
return;
|
||
}
|
||
|
||
// No trigger: keep toast hidden.
|
||
}
|
||
|
||
function setupMemToastWiring() {
|
||
const close = document.getElementById('mem-toast-close');
|
||
if (close) close.addEventListener('click', dismissMemToast);
|
||
const snooze = document.getElementById('mem-toast-snooze');
|
||
if (snooze) {
|
||
snooze.addEventListener('click', async () => {
|
||
// Snooze logic: bump the thresholds above the current snapshot so the
|
||
// toast won't re-fire until the user has accumulated MORE tabs or one
|
||
// tab has grown ANOTHER 2 GB beyond what we just warned about. Stored
|
||
// in chrome.storage.session so a sidebar reload doesn't lose the
|
||
// snooze (but a Chrome restart does).
|
||
try {
|
||
const resp = await fetch(`${serverUrl}/memory`, {
|
||
headers: { 'Authorization': `Bearer ${serverToken}` },
|
||
signal: AbortSignal.timeout(MEM_POLL_TIMEOUT_MS),
|
||
credentials: 'include',
|
||
});
|
||
if (resp.ok) {
|
||
const snap = await resp.json();
|
||
const tabs = Array.isArray(snap.tabs) ? snap.tabs : [];
|
||
memToastSnooze.tabsAbove = tabs.length + TOAST_SNOOZE_TAB_BUMP;
|
||
const maxHeap = tabs.reduce((m, t) => Math.max(m, t.jsHeapUsed || 0), 0);
|
||
memToastSnooze.heapAbove = maxHeap + TOAST_SNOOZE_HEAP_BUMP;
|
||
await saveSnoozeState();
|
||
}
|
||
} catch (err) {
|
||
console.debug('[gstack sidebar] mem-toast snooze fetch failed:', err && err.message);
|
||
}
|
||
dismissMemToast();
|
||
});
|
||
}
|
||
void loadSnoozeState();
|
||
}
|
||
|
||
// Wire the toast on DOM ready.
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', setupMemToastWiring);
|
||
} else {
|
||
setupMemToastWiring();
|
||
}
|
||
|
||
// ─── 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.
|
||
//
|
||
// 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.
|
||
*/
|
||
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();
|
||
startMemPolling();
|
||
} else {
|
||
document.getElementById('footer-dot').className = 'dot';
|
||
document.getElementById('footer-port').textContent = '';
|
||
const memEl = document.getElementById('footer-mem');
|
||
if (memEl) {
|
||
memEl.textContent = '';
|
||
memEl.classList.remove('warn', 'bad');
|
||
}
|
||
stopMemPolling();
|
||
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') {
|
||
// /health is liveness-only — the token comes from the pinned-origin
|
||
// POST /extension-token bootstrap (our chrome-extension:// Origin
|
||
// is validated server-side against the manifest-pinned ID).
|
||
const tokenResp = await fetch(`http://127.0.0.1:${port}/extension-token`, {
|
||
method: 'POST',
|
||
signal: AbortSignal.timeout(2000),
|
||
});
|
||
const tokenData = tokenResp.ok ? await tokenResp.json() : null;
|
||
if (tokenData?.token) {
|
||
setLoadingStatus(
|
||
`Server healthy on port ${port}, connecting...`,
|
||
`token: yes (from /extension-token)\nStarting SSE + activity feed...`
|
||
);
|
||
updateConnection(`http://127.0.0.1:${port}`, tokenData.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 healthy but token bootstrap failed (attempt ${connectAttempts})`,
|
||
`POST /extension-token → ${tokenResp.status}${tokenResp.status === 403 ? ' (extension identity not trusted)' : ''}`
|
||
);
|
||
} else {
|
||
setLoadingStatus(
|
||
`Server responded but not healthy (attempt ${connectAttempts})`,
|
||
`status: ${data.status}`
|
||
);
|
||
}
|
||
} 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();
|
||
}
|
||
}
|
||
// One-time v1.62 identity-pin notice from background.js. Transient banner —
|
||
// no dedicated element in sidepanel.html since this fires once per install.
|
||
if (msg.type === 'gstack-migration-notice' && msg.message) {
|
||
console.log('[gstack sidebar]', msg.message);
|
||
try {
|
||
const banner = document.createElement('div');
|
||
banner.textContent = msg.message;
|
||
banner.style.cssText =
|
||
'position:fixed;left:8px;right:8px;bottom:40px;z-index:9999;' +
|
||
'background:#1f2937;color:#f5a623;border:1px solid #f5a623;' +
|
||
'border-radius:6px;padding:8px 10px;font-size:12px;text-align:left;';
|
||
document.body.appendChild(banner);
|
||
setTimeout(() => banner.remove(), 8000);
|
||
} catch (err) {
|
||
console.debug('[gstack sidebar] migration banner failed:', err && err.message);
|
||
}
|
||
}
|
||
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 },
|
||
}));
|
||
}
|
||
});
|
||
|
||
// ─── v1.44 pagehide: explicit PTY dispose on sidebar close ──────────
|
||
//
|
||
// Codex T3 of the eng review: WS close codes alone can't distinguish
|
||
// "intentional close" (sidebar closed, browser quit, extension reload)
|
||
// from "transient blip" (wifi hiccup) reliably — Chrome routes the
|
||
// former through code 1001 (going-away) and the latter through 1006
|
||
// (abnormal), but neither is a load-bearing contract across browsers
|
||
// and extension lifecycles.
|
||
//
|
||
// pagehide fires reliably for tab close, panel close, extension reload,
|
||
// and navigation-away. We use it to fire-and-forget a /pty-dispose POST
|
||
// so the server can synchronously dispose the PtySession instead of
|
||
// waiting for the 60s detach window (Commit 3) to time out. Zombie
|
||
// claude processes lingering for 60s on every browser quit was the
|
||
// codex-flagged failure mode.
|
||
//
|
||
// sendBeacon is the only fetch primitive that survives a closing page —
|
||
// it doesn't accept custom headers, which is why the server's
|
||
// /pty-dispose route accepts the auth token in the BODY (see
|
||
// server-pty-lease-routes.test.ts test 4).
|
||
window.addEventListener('pagehide', () => {
|
||
const sessionId = window.gstackPtySession;
|
||
const authToken = window.gstackAuthToken;
|
||
const port = window.gstackServerPort;
|
||
if (!sessionId || !authToken || !port) return;
|
||
try {
|
||
const blob = new Blob([JSON.stringify({ sessionId, authToken })], {
|
||
type: 'application/json',
|
||
});
|
||
navigator.sendBeacon(`http://127.0.0.1:${port}/pty-dispose`, blob);
|
||
} catch {
|
||
// Best-effort — the 60s detach timer will catch any session we miss.
|
||
}
|
||
});
|