mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-20 21:17:19 +02:00
* test(helpers): shared skill-census helper with three explicit counts
physicalSkillFiles (symlinked dirs included, root router included),
authoredSkills (realpath-deduped, router excluded), registryEntries
(what ./setup registers: unique frontmatter names + _gstack-command).
One counting authority for the hermetic seeder, context-bill ground
truth, and the catalog-budget test — connect-chrome's dir symlink and
the root router otherwise produce three subtly different hand-rolled
censuses. Ported-wave foundation (C11).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(evals): stop the harness grading itself
findPreviousRun excluded only the file being written, by name, so every
suite compared against _partial-e2e.json — the current run's own
accumulator, relabelled with the current tier just before each flush.
That is why every block read '+$0.00, +0s, Stable run, no regressions.'
This harness has never been able to detect a regression, and reassuring
output that cannot fail is worse than none. In-progress runs are now
excluded by role, and a run with nothing to compare against says NO
BASELINE instead of claiming stability.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit f3140b5245221fff7fb9411c7ec07c2ca11587b5)
* refactor(evals): shared partial-run predicate + finalized-run lookup
isPartialEval(data, filename) is the one place that decides what counts
as an in-progress accumulator (the _partial flag OR a _partial-prefixed
filename), and findLatestFinalizedRun(evalDir, tier) is the one place
that finds the newest real run — scanning the eval dir plus one level of
shards/<slug>/ subdirs, where the sharded paid runner points each
shard's collector. skill-budget-regression.test.ts's hand-rolled
findLatestRun (flag-blind: a flagged-but-renamed accumulator passed its
name check) is replaced by the shared helper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit b55fcf6966366fd21a8cdc46de61aab6e1b1d100)
* feat(evals): register shipped skills for hermetic PTY children
Hermetic children get a config dir that deliberately seeds no skills —
right for children that install their own, fatal for the PTY family that
TYPES /office-hours or /plan-ceo-review: claude rejects the command as
Unknown before any model turn, so the plan-family gate smokes measure
nothing. hermeticSkillsConfigDir() is a second, opt-in config dir under
the same runRoot that mirrors ./setup's registration exactly (real dir
per registry name, SKILL.md + sections/ symlinks, frontmatter-name
resolution, _gstack-command root alias), driven by the shared
skill-census so connect-chrome's dir symlink collapses the same way
setup's idempotent overwrite does.
Ported from fork commit 03c4eca2, tree walk rewritten for the upstream
layout (top-level <skill>/SKILL.md dirs, no skills/ tree). Unit tests
are new: seed shape, census parity, symlink resolution, connect-chrome
collapse, idempotence, no-API-key seed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 93dae6107b30ce453a07c2d342b60262bba6ce0b)
* feat(evals): seedSkills opt-in for PTY slash-command tests + tripwire
Wire ClaudePtyOptions.seedSkills through launchClaudePty: when set (and
hermetic, and no per-test CLAUDE_CONFIG_DIR override), the child gets
hermeticSkillsConfigDir() so typed /skill slash commands resolve instead
of dying as Unknown command before any model turn. Opted in at the three
runPlanSkill* helpers and the four direct-launch slash-command tests
(plan-design-with-ui, plan-ceo-mode-routing, autoplan-chain,
ship-idempotency).
New static tripwire (test/pty-skill-seeding-wiring.test.ts): any test
file that sends a slash command over the PTY must route through a
runPlanSkill* helper or pass seedSkills: true — an unseeded slash-command
test spends money and measures nothing. hermetic-wiring.test.ts now
blesses the repo-tree seeding path explicitly (config dir under runRoot,
symlinks into the repo checkout, never operator ~/.claude).
The CI "Register gstack skills for PTY smoke" step keeps a keep-me note:
container cross-mount symlinks defeat the TUI scanner and HOME is not
hermeticized, so the real-file copies there must survive this change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 63c52269daaffb833b3105ea9b4b99be6df8fec7)
* refactor(evals): single shared paid-test-set module
test/helpers/paid-test-set.ts is now the one definition of which test
files are paid (the exact globs package.json's test:gate expands).
scripts/test-free-shards.ts derives its free/paid exclusion from it
instead of a private regex list, dropping the dead
browse/test/security-review-fullstack.test.ts pattern (file no longer
exists). The sharded paid runner derives its enumeration from the same
module, so a file added to one list can no longer silently miss the
other.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit a7f36479a6a1f3656452370f5883371f3cb65623)
* feat(evals): env-driven lazy eval dir + shard-aware store and tooling
Importing eval-store no longer spawns the gstack-slug subprocess: the
module-level DEFAULT_EVAL_DIR constant is now a memoized defaultEvalDir()
resolved at collector construction. Resolution order: explicit
constructor arg, then GSTACK_EVAL_DIR, then slug detection — so the
sharded paid runner can point each shard child at its own
<evalDir>/shards/<slug>/ dir with plain env, no --preload.
Runs collected under a shards/ subdir record their slug in the eval
JSON (EvalResult.shard). findPreviousRun scans one shards/<slug>/ level
and prefers same-slug priors, so each shard baselines against its own
history instead of whichever shard flushed last. eval:list,
eval:summary, and eval:compare enumerate the same one level of shard
subdirs; eval:compare's no-arg mode also stops picking an in-progress
accumulator as the after-run.
eval-watch stays flat (documented follow-up): it tails a single dir for
live progress and gains nothing from per-shard baselines until the
runner emits a merged stream.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit e1f53f7d9c7fe6b65877d843f2e25bd2e2d12ffd)
* feat(evals): sharded paid tier runner
scripts/test-paid-shards.ts runs the gate/periodic tier one Bun process
per test file, with an EXTERNAL wall-clock timeout that SIGKILLs the
shard's detached process group and an aggregate that distinguishes
passed / failed / timed-out / never-started — partial execution can no
longer read as a pass. Bun's native --shard/--isolate covers none of
this: no process-group kill (hung claude/codex PTY grandchildren
survive in-process isolation), no never-started taxonomy, no per-shard
env. Each shard child gets GSTACK_EVAL_DIR=<evalDir>/shards/<slug>/
(slug = test filename sans extension, stable across runs) so shard
baselines compare against their own prior runs.
Output classification lives in scripts/test-strict-output.ts (strict
exit-code derivation, incremental fail-line classifier, child signal
forwarding) so the runner and any future strict bun-test wrapper share
one implementation. Enumeration derives from the shared paid-test-set
module; tier exclusion fires only on an explicit whole-file
EVALS_TIER === '<other>' guard.
package.json gains test:gate:sharded / test:periodic:sharded, and
eval:bg:gate / eval:bg:periodic now run the sharded scripts with detach
timeouts sized to the worst case (gate: 49 shards x 30min / 4 jobs ~
6.2h -> 25200s; periodic: 59 -> 28800s).
test/paid-shards.test.ts pins enumeration, tier classification, and the
kill-and-continue property with a real busy-loop shard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 5e76bd5931836257f896cedfe4e93912cb759c70)
* feat(security): hash-chained egress receipt ledger (core)
Port lib/egress-receipt from the v2 fork as TypeScript: writeReceipt
(sync, fail-closed via typed EGRESS_RECEIPT_FAILED), best-effort
writeOutcome, readLedger/listReceipts/verifyLedger, GSTACK_HOME ->
GSTACK_STATE_DIR -> ~/.gstack resolution, 0600 ledger under a 0700
security dir, and an mkdir spin lock (2.5s budget) with documented
>10s-mtime stale-lock reclaim.
Changes vs the fork:
- lastRawLine tail-reads the final 4KB instead of loading the whole
ledger, so appends stay O(1) as the file grows.
- WARN-at-size: past 25MB writeReceipt emits one self-explanatory
stderr warning per process (what the ledger is, how to inspect it,
rotation TODO); verifyLedger gains a sizeWarning field. Rotation
TODO carries the chain-genesis sketch (new generation's first record
embeds the prior file's tail hash).
bin/gstack-egress-receipt is a bun script bridging shell callers:
write|outcome subcommands, exit 3 + EGRESS_RECEIPT_FAILED on stderr on
failure; --no-payload records sha256:null for git-class ops.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 619726a3d77d987a2e50151a5727b3faaaf5fc6a)
* chore(bin): delete dead brain-consumer/reader scripts
bin/gstack-brain-consumer and bin/gstack-brain-reader are byte-identical
dead scripts that POST the repo URL + a Bearer token to a /ingest-repo
endpoint gbrain removed (docs/gbrain-sync.md already documents the
removal in past tense). No live references remain; CHANGELOG mentions
are historical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 254ddc69fc5a0270fcc973e36b6a81766d835d2d)
* feat(security): shared shell receipt helpers
bin/gstack-egress-lib.sh (sourced library, gstack-gbrain-lib.sh
precedent) provides _receipted_curl and _receipted_git: write the
egress receipt BEFORE the send via gstack-egress-receipt, hand curl the
SAME payload file via --data-binary @file so the receipt hash matches
the wire bytes exactly, then append a best-effort outcome. Per-call
fail policy: 'closed' refuses the send (return 3, problem/cause/fix
message on stderr) and 'open' warns and proceeds. Payload temp files
are consumed immediately per call — no EXIT traps, since callers like
gstack-telemetry-sync own their own EXIT trap and a sourced trap would
clobber it.
Tested end-to-end against a local Bun.serve listener: receipt sha256
equals the sha256 of the bytes the listener received, fail-closed
refusal never touches the network and carries the problem/cause/fix
stderr shape, fail-open warns and proceeds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 6d067dce2d4c8815dec98be551763c85a3671357)
* feat(security): receipt core shell sinks
Wire the three core bash egress sinks through gstack-egress-lib.sh:
- gstack-telemetry-sync: the batch POST now writes the payload to a
temp file, receipts those exact bytes fail-closed, and hands curl the
SAME file. On refusal nothing is sent and the cursor does not
advance, so the batch stays buffered for the next run. The HTTP
status is recorded as the receipt outcome.
- gstack-update-check: fail-open receipts (warn + proceed) on the
Supabase ping POST, both VERSION curls (via a local
_receipted_version_fetch helper that skips non-network schemes), and
git ls-remote. The ping receipt is written inside the backgrounded
subshell, so it can never block the script's exit.
- gstack-brain-sync: fail-closed git-class receipts. The push receipt
is written BEFORE the commit consumes the queue, so a refused receipt
leaves the queue intact and the next run retries the whole drain
(pinned by a new queue-intact-on-refusal test, including the
problem/cause/fix refusal message shape). The retry-path fetch and
retry push carry their own fail-closed receipts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 3c60f699acceaf1c92a218874711e05fc17dca5d)
* feat(security): receipt TS module sinks + tunnel
writeReceipt (fail-closed, sha256:null — a subprocess or SDK owns the
wire bytes) before every TS-module network-bearing operation:
- bin/gstack-gbrain-sync.ts: before the gbrain code walk that ships
repo content to the user's gbrain DB (may be remote Postgres). A
refused receipt fails the stage with status refused-egress-receipt.
- bin/gstack-memory-ingest.ts: before the gbrain batch import of
transcript pages. A refused receipt returns a system_error verdict
without spawning the import.
- browse/src/server.ts: before both ngrok.forward call sites (start-up
BROWSE_TUNNEL=1 path and the /tunnel/start endpoint). A receipt
failure lands in the existing catch that tears the tunnel listener
back down and refuses the start.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 5677d618a48fcd0ae2b068bf868781d90f809cb5)
* feat(design): receipted fetch for OpenAI calls
design/src/receipted-fetch.ts wraps every api.openai.com call: a
content-free egress receipt (sink design-openai, sha256 of the JSON
body — hash only, never the body) is written BEFORE the send. Polarity
is FAIL-OPEN: user-facing generation must not die because an audit log
hiccuped, so a receipt failure warns on stderr and the call proceeds.
Streams pass through untouched (response bodies returned as-is;
non-string request bodies receipted as sha256:null rather than drained
to hash).
All ten call sites converted with per-command payload classes:
generate, variants (injected fetchFn passes through), iterate (both
threaded and fresh paths), evolve (image + screenshot analysis), check,
diff, design-to-code, memory.
Unit-tested with injected fetch: receipt-before-send ordering, stream
passthrough, and fail-open on an unwritable ledger.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit c0e5ff6639414ac2fd98e8ac3affb51401746b55)
* feat(security): receipt admin scripts + user git-ops (zero exceptions)
Wire the remaining shell egress through gstack-egress-lib.sh:
- gstack-gbrain-mcp-verify: both JSON-RPC probe POSTs (initialize +
tools/list) receipted fail-closed via payload files (hash == wire
bytes). A refused receipt lands in the NETWORK class — no send.
- gstack-security-dashboard / gstack-community-dashboard: the
community-pulse GETs receipted fail-open (read-only stats must not
break over an audit hiccup).
- gstack-gbrain-supabase-provision: api_call receipted fail-closed.
Each retry attempt hands the helper a fresh copy of the body file
(the helper consumes its payload). The receipt hashes the request
body only — the PAT never reaches the ledger or any log. Refusal
exits 8 without retrying.
- git-class sha256:null receipts, fail-open: gstack-artifacts-init
(ls-remote, initial push, fetch/pull recovery, retry push),
gstack-brain-restore (staging clone, existing-repo fetch),
gstack-session-update (self-update pull).
gstack-team-init needs no wiring: every git clone in it is inside an
echoed instruction string, not an executed command.
The lib now self-locates with shell builtins only (no dirname), so
sourcing works under the whitelist-PATH test harnesses.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit b8c5e2055b21ab72878b3e46f8047782ee65a11c)
* test(security): egress wiring tripwire + polarity contract
Static-grep tripwire pinning the egress-receipt wiring (threat model in
the header: the ledger is forensic observability of ATTEMPTED egress,
not an exfiltration control):
- Per-sink assertions: every wired TS module imports egress-receipt and
calls writeReceipt; every wired shell sink sources
gstack-egress-lib.sh with each network op under a receipt;
ngrok-proximity check for server.ts; every design api.openai.com call
routes through receiptedFetch.
- Absence assertions: the dead brain-consumer/reader scripts stay
deleted (lstat, so a dangling symlink also fails).
- Polarity table pinned as data (fail-closed: brain-sync,
memory-ingest, gbrain-sync, telemetry-sync, ngrok, mcp-verify,
supabase-provision; fail-open: design-openai, update-check,
dashboards, git-class user ops, context-bill --exact) plus per-file
polarity spot-checks.
- NEW-SINK SCANNER with zero KNOWN_UNWIRED: sweeps bin/, lib/,
scripts/, design/src, browse/src for curl, absolute-URL fetch(, and
git remote ops (never local rev-parse/get-url; heredoc bodies and
message strings excluded) and requires every hit to be receipted or
in a REASONED exemption list where each entry carries its why.
Preamble-generated skill prose documented out-of-scope in the header.
- Shebang tripwire: no bin/gstack-* file may carry a node shebang.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit ff69ceeafaf9c017d539b6ad77ff8f95b680b979)
* feat(cli): gstack-egress reader
bin/gstack-egress (bun) — the auditor's view of the receipts ledger:
- list: one row per receipt (what gstack ATTEMPTED to send), with
--since/--host/--sink filters and --json.
- verify: recompute the hash chain; exit 3 on tamper naming the first
broken line; prints the sizeWarning when the ledger passes 25MB.
- grants: what CAN leave, built on the upstream config keys only
(telemetry, artifacts_sync_mode, redact_repo_visibility,
redact_prepush_hook via gstack-config get) — each grant names its
file, key, and the exact revoke command.
CLI smoke tests spawn the real bin against a temp GSTACK_HOME,
including a broken-chain fixture asserting exit 3.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 9e24eca0f1069fea2ea69e7df4e9b256e93d59a3)
* feat(cli): context-bill — token bill-of-materials (stripped port)
lib/context-bill.ts, ported from the v2 fork and STRIPPED to the tiers
this repo's skills can exercise: ALWAYS-ON (per-skill frontmatter bytes
with dead-key and foreign-host-file flags), EAGER (SKILL.md + any
forced 'for every invocation' references), on-disk totals, --diff,
--budget, and --exact with the calibration table. The fork's
CONDITIONAL/TRANSITIVE/LAZY/FAST-PATH parsers understand only its
dispatcher layout and were dropped; the tier fields stay in the report
shape (empty/zero/null) so re-adding a parser is additive.
TOKEN_DIVISORS and their provenance docblock kept; --help notes
recalibration via --exact's calibration block.
Three upstream fixes over the fork:
(a) findSkillDirs treats the walk ROOT as a container — the repo root's
router SKILL.md is billed AND its children are walked (the fork
short-circuited and billed one skill); walkMd skips node_modules
and dot-directories.
(b) installed-tree layout: subdirs that are their own repo checkout
(a gstack/ clone inside ~/.claude/skills, detected by .git) are
skipped, and directory symlinks (connect-chrome) are followed with
a container-recursion cycle guard.
(c) ROUTER_KEYS widened to the upstream frontmatter contract {name,
description, version, allowed-tools, triggers, preamble-tier}.
--exact writes an egress receipt (sink context-bill-exact, host
api.anthropic.com) BEFORE any count_tokens POST; if the receipt cannot
be written the run degrades to the offline estimate with a warning —
nothing is sent unrecorded. bin/gstack-context-bill is the bun shim.
Tests: fixture-tree ledgers, the three fixes, --diff/--budget exit
codes, --exact with injected fetch (envelope subtraction, receipt
ordering, fail-open degradation), CLI smoke test, and ground truth
against THIS repo via test/helpers/skill-census.ts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 675c19876b87ec927b555f5f64c7f93130b3de90)
* test(catalog): aggregate discovery-surface budget with ratchet protocol
Every host loads every skill's frontmatter name + description at
discovery, every session. applyCatalogTrim in scripts/gen-skill-docs.ts
shapes each description and the 160KB per-file warn covers body size,
but nothing capped the aggregate frontmatter — the catalog could grow
one reasonable-looking description at a time. This test is that
enforcement layer.
Measures the catalog via test/helpers/skill-census.ts authoredSkills
(symlink-deduped, root router counted separately as the _gstack-command
alias line item): 53 skills + router = 4,420 bytes = 1,105
token-equivalents today, asserted <= 1,150 (~4% headroom). Per-skill
sub-cap of 260 bytes (largest today: design-consultation at 229), plus
a non-empty-description check.
Failure messages are self-service ratchets: they print the new total,
the delta, and the update protocol (bump the constant AND the
derivation comment in the same commit; trim instead of grow for
existing descriptions). Parser handles folded block scalars
(description: >-) for fork parity; import-free by design so it
survives generator refactors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit c106fb36f768181b80c257e5cff1cde4f435f9c0)
* fix(browse): extension token bootstrap moves to pinned-origin POST; /health carries no token
GET /health is now liveness/status only in every mode — both token
carve-outs (headed-mode disjunct AND chrome-extension:// Origin
disjunct) are removed. Token bootstrap is POST /extension-token on the
local listener: the Origin header must be exactly
chrome-extension://<GSTACK_EXTENSION_ID> and the Host header's hostname
must parse to 127.0.0.1 or localhost (parsed via new URL, never literal
equality — Host arrives as '127.0.0.1:34567'). Wrong origin/host → 403
with no detail. The tunnel surface 404s the endpoint (not in
TUNNEL_PATHS, verified by test).
The extension ID is pinned by a new "key" field (RSA public key) in
extension/manifest.json; browse/scripts/extension-id.ts reproduces the
ID derivation (first 16 bytes of SHA-256 of the DER public key, hex
mapped 0-9a-f → a-p). The private key is not committed anywhere —
unpacked/baked-in loads only need the public key.
Extension side: background.js bootstraps and refreshes the token via
POST /extension-token (403 → disconnected state); sidepanel.js direct
connect path does the same; sidepanel-terminal.js's dead /health token
fallback (read AUTH_TOKEN/authToken keys the server never sent,
hardcoded port) is replaced with the window.gstackAuthToken path.
MIGRATION NOTE: the manifest key pins the extension ID, so existing
installs' side-panel local state (saved port, snoozes) resets once —
explained in-product via a one-time notice (flag
gstack_id_migrated_v162). After upgrading the server, restart the
browser so the old service worker stops polling for a token GET /health
no longer serves.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit e9a0b6847a2d17fe6656a4686b4efd0c8380eb09)
* docs: correct stale compiled-binaries claim; file three egress/eval follow-ups
CLAUDE.md's compiled-binaries section claimed browse/dist binaries are
tracked by git and appear as modified in git status — false since
64d5a3e4 (v0.11.16.0) untracked them, and actively harmful: it trained
agents to ignore dist binaries in git status. The section now states
the truth (untracked + gitignored; a dist binary in git status means
someone force-added it) and covers make-pdf/dist too.
TODOS.md gains the three follow-ups filed by the v1.62 port-wave
reviews: ledger rotation with chain-genesis records, launch-nonce
token bootstrap, and eval-watch shard-awareness.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: pre-landing review fixes for the v2 port wave
Review army (checklist + 5 specialists) + coverage/plan audits on the
assembled branch. Genuine correctness/security/hygiene fixes:
- test-paid-shards: strictTestExitCode now receives expectedFiles on the
real bun path, so a shard that runs fewer files than planned (harness
crash, nothing loaded) with exit 0 is no longer recorded 'passed' — the
invisible-non-execution class the runner exists to kill. Pinned by the
new test/strict-output.test.ts (also covers the chunk-boundary classifier).
- test-paid-shards: EVALS_TIER env is validated (gate|periodic) like the
--tier flag, so a typo can't self-skip every test and exit 0 green.
- package.json: test:periodic:sharded sets EVALS_ALL=1, restoring the
full-tier semantics the pre-shard script had (CI already set it; local
eval:bg:periodic silently under-measured without it).
- brain-sync.test: run() pins HOME to the temp home so gstack-artifacts-init
stops writing/clobbering the operator's real ~/.gstack-artifacts-remote.txt
every free-suite run; afterEach now also scrubs the current filename.
- egress-receipt: cap each receipt field at 512B so a serialized line always
fits the 4KB tail-read window — a longer line would make the next append
hash a truncated prior line and verifyLedger report a permanent false
TAMPER. warnLedgerSize short-circuits before statSync once fired (append
hot path).
- gstack-egress: import.meta.dir (Windows-safe) instead of new URL().pathname
so grants doesn't silently report defaults on Windows; strip control chars
from ledger-derived fields on render so a crafted receipt can't spoof the
auditor's view.
- extension/background.js + CLAUDE.md: renumber the identity-pin migration
refs v1.62 -> v1.63 (main claimed 1.62.0.0; this wave queue-advances).
- egress-receipt-wiring: pin lib/context-bill.ts unconditionally (both land
together now); drop the dead RunShardsOptions.tier field.
All fix-affected test files green; gate failures triaged as external-env
(codex/gemini CLI drift) or pre-existing (hermetic-canary fails identically
on base). Deferred polish tracked in the PR body + decision store.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: bump version and changelog (v1.63.0.0)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: file TODO to harden plan-design-with-ui PTY detection
The v1.63 seedSkills change made this gate test execute for the first
time; it reliably times out because its terminal scraper can't parse the
(correctly-rendered) scope-gate AskUserQuestion out of a spinner-mangled
PTY buffer. Shipped skill behavior is correct — test-harness limitation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: re-slot release as v1.62.1.0 (PATCH per user)
Main claimed 1.62.0.0 while the wave was in flight; the user chose the
PATCH slot over queue-advancing MINOR. Renumbers the identity-pin
migration notice (now version-free flag name so a re-slot never orphans
an already-set flag), the CLAUDE.md /health note, the CHANGELOG heading,
and the TODOS section titles.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): stop hard-requiring the literal ok) case label in gbrain-refresh guards
The extractor grepped for 'ok)' but the case label grew to
ok|timeout|thin-client) (#1964, #2051), so the whole file errored on
import — the free suite's only red for months. The extractor now matches
any label starting with ok and its alternations; all 7 guard assertions
run again.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): hermetic-canary probes with ${VAR:-} so nounset shells can't fail success
The probe echoed bare $CONDUCTOR_WORKSPACE_PATH — when scrubbing WORKS
the var is unset, and under a nounset shell the echo errors, failing the
canary exactly when isolation succeeds. Defaulted expansions assert
identically under any shell. Fails identically on base; fixed here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(evals): absorb codex/gemini CLI drift; external-service tests go periodic-tier
- codex exec gains --skip-git-repo-check: newer CLIs refuse exec in an
untrusted non-git dir (our temp skill dirs) — empirically verified.
- gemini: --skip-trust was removed in gemini-cli 0.34 (argv parse error);
dropped from the session runner and the benchmark adapter. A present-
but-unusable CLI (deprecated individual code-assist auth path) now
classifies as SKIP, not a false adapter failure; the benchmark live
smoke skips on auth/rate_limit error codes (environmental) while still
failing on timeout/unknown (the drift classes it exists to catch).
- codex-e2e, gemini-e2e, and benchmark-providers gain the canonical
whole-file EVALS_TIER === 'periodic' guard per CLAUDE.md tiering rule 3
(external service -> periodic) — the sharded gate runner now excludes
all three (gate: 45 -> 42 shards).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(evals): parse single-logical-line AskUserQuestions in the PTY runner
When the PTY reflows a boxed AUQ, ALL options land on ONE logical line
after stripAnsi — parseNumberedOptions parsed one option per line, found
only '1.', and the >=2 check failed forever while the correct question
sat on screen (plan-design-with-ui timed out this way twice, with the
rendered scope-gate AUQ visible in both failure buffers). The cursor
line is now parsed as a stream of ascending N. tokens; DEC cursor-
visibility residue is stripped before matching; plan-design-with-ui's
budgets grow to fit observed ~6min preamble+thinking latency. Pinned by
test/pty-auq-single-line.test.ts using the real failure buffers; all 142
existing parser-consumer unit tests still green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: restore v1.63.0.0 (MINOR — user-confirmed final slot)
The wave ships new capability (egress receipts + two CLIs, sharded paid
runner, hermetic skill seeding) at ~8K lines — MINOR scale per the
scale-aware bump rules. Supersedes the brief v1.62.1.0 re-slot; the
version-free migration flag means no state churn from the renumber.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: spell out AskUserQuestion in the PTY single-line fixture
Rename test/pty-auq-single-line.test.ts to
test/pty-askuserquestion-single-line.test.ts and expand the AUQ
abbreviation in identifiers and comments. House style writes
AskUserQuestion in full in filenames, identifiers, and comments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: sync every doc surface with the v1.63 release
/document-release audit (4-lane, all claims verified against branch code):
- README: gstack-egress + gstack-context-bill rows in the standalone-binaries
table; Privacy & Telemetry gains the receipted-egress bullet (attempted-
egress framing per the shipped threat model).
- ARCHITECTURE: /health is liveness-only, POST /extension-token endpoint row
+ bootstrap mechanics paragraph; new Egress receipt ledger subsection under
Security model; eval persistence covers the sharded runner, GSTACK_EVAL_DIR,
and the finalized-run baseline rule.
- CLAUDE.md: sharded test scripts in Commands; sharded semantics in the
detached-evals section; PTY skill seeding in the hermetic section; egress
invariant block beside the other server-egress invariants; catalog-budget
ceiling beside the 160KB token ceiling; project-tree entries for
lib/egress-receipt.ts, lib/context-bill.ts, scripts/test-paid-shards.ts.
- CONTRIBUTING: seedSkills + live-tree seeding in the hermetic paragraph;
sharded runner in detached runs; catalog-budget in the Tier 1 list.
- BROWSER: extension token bootstrap section, tunnel egress receipts section,
identity-pin migration note in manual install.
- REMOTE_BROWSER_ACCESS: tunnel-start receipt bullet in the security model.
- gbrain docs: /sync-gbrain + brain-sync egress-receipt behavior documented;
dead consumer-token instructions removed (consumer machinery deleted this
release); new fail-closed refusal added to the error catalog.
- CHANGELOG: measured-vs-ceiling catalog numbers, contributor notes for the
external-service tier move and the PTY single-line AskUserQuestion parser,
release date.
- TODOS: /health token-distribution TODO resolved by this release, removed;
port-wave follow-up sections re-labeled to the shipped version.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: sweep drift that predates this release
Surfaced by the /document-release audit; every fix verified against the
current binaries:
- gstack-brain-init was replaced by gstack-artifacts-init in v1.27.0.0
(hard-delete, no compat shim), but README, USING_GBRAIN_WITH_GSTACK,
docs/gbrain-sync.md, and docs/gbrain-sync-errors.md still instructed
users to run it — command-not-found on every follow. Same sweep updates
~/.gstack-brain-remote.txt to the canonical ~/.gstack-artifacts-remote.txt
(legacy name still honored on restore, noted where users copy the file).
- gbrain-sync-errors.md headings re-matched to the literal messages the
binaries print today (the doc's whole value is grep-by-exact-message):
'gstack-artifacts-init: ~/.gstack/ is already a git repo pointing at:',
'Remote not reachable via SSH:', 'Failed to create or find ...'. The
already-a-repo fix now leads with the command's own set-url suggestion.
- docs/gbrain-sync.md 'Under the hood' linked a plan file that does not
exist in the repo; replaced with the decisions themselves.
- SIDEBAR_MESSAGE_FLOW startup timeline: /pty-session responds with
{terminalPort, sessionId, attachToken, leaseExpiresAt} (v1.44 shape,
verified at browse/src/server.ts:1860), not the retired
{terminalPort, ptySessionToken} pair.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: fold the Codex accuracy review of the release docs
Six findings, all verified against source before fixing:
1. 'Every send writes a receipt' overclaimed — fail-open sinks proceed with
a stderr warning when the receipt write fails, so a fail-open send can go
unrecorded (lib/egress-receipt.ts:8-14). Descriptive prose now says so;
the receipted framing keeps 'attempted'.
2. 'Receipts hash the request body' is wrong for subprocess-owned sends —
git pushes record sha256: null (lib/egress-receipt.ts:71).
3. 'grants shows every consent in force' overclaimed — it reports the four
standing config settings (bin/gstack-egress:139-181). Reworded in
README, ARCHITECTURE, and the CHANGELOG entry.
4. 'Zero-exception scanner' vs reality: the new-sink scanner carries a
reasoned SCANNER_EXEMPT list (user-directed fetches, probes, instruction
strings, skill prose). CLAUDE.md now names it.
5. Error-catalog cause/fix for the receipt refusal: the writer mkdirs the
ledger dir itself, so 'missing' isn't a cause and bare chmod fails when
it is absent — cause reworded, fix is mkdir -p && chmod.
6. gbrain-sync first-run steps described the retired binary's behavior:
default repo is gstack-artifacts-$USER, and init PRINTS the gbrain
hookup command (never auto-executes; bin/gstack-artifacts-init:384-419).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local>
1448 lines
56 KiB
JavaScript
1448 lines
56 KiB
JavaScript
/**
|
||
* gstack browse — Side Panel
|
||
*
|
||
* Terminal pane (default): live claude PTY via xterm.js, driven by
|
||
* sidepanel-terminal.js. The chat queue + sidebar-agent.ts were ripped
|
||
* in favor of the interactive REPL — no more one-shot claude -p.
|
||
*
|
||
* Debug tabs (behind the `debug` toggle): activity feed (SSE) + refs +
|
||
* inspector. Quick-actions toolbar (Cleanup / Screenshot / Cookies)
|
||
* lives at the top of the Terminal pane.
|
||
*/
|
||
|
||
const NAV_COMMANDS = new Set(['goto', 'back', 'forward', 'reload']);
|
||
const INTERACTION_COMMANDS = new Set(['click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait', 'upload']);
|
||
const OBSERVE_COMMANDS = new Set(['snapshot', 'screenshot', 'diff', 'console', 'network', 'text', 'html', 'links', 'forms', 'accessibility', 'cookies', 'storage', 'perf']);
|
||
|
||
let lastId = 0;
|
||
let eventSource = null;
|
||
let serverUrl = null;
|
||
let serverToken = null;
|
||
let connState = 'disconnected'; // disconnected | connected | reconnecting | dead
|
||
let reconnectAttempts = 0;
|
||
let reconnectTimer = null;
|
||
const MAX_RECONNECT_ATTEMPTS = 30; // 30 * 2s = 60s before showing "dead"
|
||
|
||
// Auth headers for sidebar endpoints
|
||
function authHeaders() {
|
||
const h = { 'Content-Type': 'application/json' };
|
||
if (serverToken) h['Authorization'] = `Bearer ${serverToken}`;
|
||
return h;
|
||
}
|
||
|
||
// ─── Connection State Machine ─────────────────────────────────────
|
||
|
||
function setConnState(state) {
|
||
const prev = connState;
|
||
connState = state;
|
||
const banner = document.getElementById('conn-banner');
|
||
const bannerText = document.getElementById('conn-banner-text');
|
||
const bannerActions = document.getElementById('conn-banner-actions');
|
||
|
||
if (state === 'connected') {
|
||
if (prev === 'reconnecting' || prev === 'dead') {
|
||
// Show "reconnected" toast that fades
|
||
banner.style.display = '';
|
||
banner.className = 'conn-banner reconnected';
|
||
bannerText.textContent = 'Reconnected';
|
||
bannerActions.style.display = 'none';
|
||
setTimeout(() => { banner.style.display = 'none'; }, 5000);
|
||
} else {
|
||
banner.style.display = 'none';
|
||
}
|
||
reconnectAttempts = 0;
|
||
if (reconnectTimer) { clearInterval(reconnectTimer); reconnectTimer = null; }
|
||
} else if (state === 'reconnecting') {
|
||
banner.style.display = '';
|
||
banner.className = 'conn-banner reconnecting';
|
||
bannerText.textContent = `Reconnecting... (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`;
|
||
bannerActions.style.display = 'none';
|
||
} else if (state === 'dead') {
|
||
banner.style.display = '';
|
||
banner.className = 'conn-banner dead';
|
||
bannerText.textContent = 'Server offline';
|
||
bannerActions.style.display = '';
|
||
if (reconnectTimer) { clearInterval(reconnectTimer); reconnectTimer = null; }
|
||
} else {
|
||
banner.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function startReconnect() {
|
||
if (reconnectTimer) return;
|
||
setConnState('reconnecting');
|
||
reconnectTimer = setInterval(() => {
|
||
reconnectAttempts++;
|
||
if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
|
||
setConnState('dead');
|
||
return;
|
||
}
|
||
setConnState('reconnecting');
|
||
tryConnect();
|
||
}, 2000);
|
||
}
|
||
|
||
|
||
// ─── Chat path ripped ────────────────────────────────────────────
|
||
// Chat queue + sendMessage + pollChat + switchChatTab + browser-tabs
|
||
// strip + security banner all lived here. Replaced by the interactive
|
||
// claude PTY in sidepanel-terminal.js (and terminal-agent.ts on the
|
||
// server side).
|
||
|
||
// ─── Reload Sidebar ─────────────────────────────────────────────
|
||
document.getElementById('reload-sidebar').addEventListener('click', () => {
|
||
location.reload();
|
||
});
|
||
|
||
// ─── Copy Cookies ───────────────────────────────────────────────
|
||
document.getElementById('chat-cookies-btn').addEventListener('click', async () => {
|
||
if (!serverUrl) return;
|
||
// Navigate the browser to the cookie picker page hosted by the browse server
|
||
try {
|
||
await fetch(`${serverUrl}/command`, {
|
||
method: 'POST',
|
||
headers: authHeaders(),
|
||
body: JSON.stringify({ command: 'goto', args: [`${serverUrl}/cookie-picker`] }),
|
||
});
|
||
} catch (err) {
|
||
console.error('[gstack sidebar] Failed to open cookie picker:', err.message);
|
||
}
|
||
});
|
||
|
||
// ─── Debug Tabs ─────────────────────────────────────────────────
|
||
|
||
const debugToggle = document.getElementById('debug-toggle');
|
||
const debugTabs = document.getElementById('debug-tabs');
|
||
const closeDebug = document.getElementById('close-debug');
|
||
let debugOpen = false;
|
||
|
||
// The Terminal pane is the only primary surface; Activity / Refs / Inspector
|
||
// are debug overlays behind the `debug` toggle. Closing debug returns to
|
||
// the Terminal pane, which is always present.
|
||
const PRIMARY_PANE_ID = 'tab-terminal';
|
||
|
||
function showPrimaryPane() {
|
||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||
document.getElementById(PRIMARY_PANE_ID).classList.add('active');
|
||
document.querySelectorAll('.debug-tabs .tab').forEach(t => t.classList.remove('active'));
|
||
}
|
||
|
||
debugToggle.addEventListener('click', () => {
|
||
debugOpen = !debugOpen;
|
||
debugToggle.classList.toggle('active', debugOpen);
|
||
debugTabs.style.display = debugOpen ? 'flex' : 'none';
|
||
if (!debugOpen) showPrimaryPane();
|
||
});
|
||
|
||
closeDebug.addEventListener('click', () => {
|
||
debugOpen = false;
|
||
debugToggle.classList.remove('active');
|
||
debugTabs.style.display = 'none';
|
||
showPrimaryPane();
|
||
});
|
||
|
||
document.querySelectorAll('.debug-tabs .tab:not(.close-debug)').forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
document.querySelectorAll('.debug-tabs .tab').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||
tab.classList.add('active');
|
||
document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active');
|
||
|
||
if (tab.dataset.tab === 'refs') fetchRefs();
|
||
});
|
||
});
|
||
|
||
// ─── Activity Feed ──────────────────────────────────────────────
|
||
|
||
function getEntryClass(entry) {
|
||
if (entry.status === 'error') return 'error';
|
||
if (entry.type === 'command_start') return 'pending';
|
||
const cmd = entry.command || '';
|
||
if (NAV_COMMANDS.has(cmd)) return 'nav';
|
||
if (INTERACTION_COMMANDS.has(cmd)) return 'interaction';
|
||
if (OBSERVE_COMMANDS.has(cmd)) return 'observe';
|
||
return '';
|
||
}
|
||
|
||
function formatTime(ts) {
|
||
const d = new Date(ts);
|
||
return d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||
}
|
||
|
||
let pendingEntries = new Map();
|
||
|
||
function createEntryElement(entry) {
|
||
const div = document.createElement('div');
|
||
div.className = `activity-entry ${getEntryClass(entry)}`;
|
||
div.setAttribute('role', 'article');
|
||
div.tabIndex = 0;
|
||
|
||
const argsText = entry.args ? entry.args.join(' ') : '';
|
||
const statusIcon = entry.status === 'ok' ? '\u2713' : entry.status === 'error' ? '\u2717' : '';
|
||
const statusClass = entry.status === 'ok' ? 'ok' : entry.status === 'error' ? 'err' : '';
|
||
const duration = entry.duration ? `${entry.duration}ms` : '';
|
||
|
||
div.innerHTML = `
|
||
<div class="entry-header">
|
||
<span class="entry-time">${formatTime(entry.timestamp)}</span>
|
||
<span class="entry-command">${escapeHtml(entry.command || entry.type)}</span>
|
||
</div>
|
||
${argsText ? `<div class="entry-args">${escapeHtml(argsText)}</div>` : ''}
|
||
${entry.type === 'command_end' ? `
|
||
<div class="entry-status">
|
||
<span class="${statusClass}">${statusIcon}</span>
|
||
<span class="duration">${duration}</span>
|
||
</div>
|
||
` : ''}
|
||
${entry.result ? `
|
||
<div class="entry-detail">
|
||
<div class="entry-result">${escapeHtml(entry.result)}</div>
|
||
</div>
|
||
` : ''}
|
||
`;
|
||
|
||
div.addEventListener('click', () => div.classList.toggle('expanded'));
|
||
return div;
|
||
}
|
||
|
||
function addEntry(entry) {
|
||
const feed = document.getElementById('activity-feed');
|
||
const empty = document.getElementById('empty-state');
|
||
if (empty) empty.style.display = 'none';
|
||
|
||
if (entry.type === 'command_end') {
|
||
for (const [id, el] of pendingEntries) {
|
||
if (el.querySelector('.entry-command')?.textContent === entry.command) {
|
||
el.remove();
|
||
pendingEntries.delete(id);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
const el = createEntryElement(entry);
|
||
feed.appendChild(el);
|
||
if (entry.type === 'command_start') pendingEntries.set(entry.id, el);
|
||
el.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||
|
||
if (entry.url) document.getElementById('footer-url')?.textContent && (document.getElementById('footer-url').textContent = new URL(entry.url).hostname);
|
||
lastId = Math.max(lastId, entry.id);
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
const div = document.createElement('div');
|
||
div.textContent = str;
|
||
// DOM text-node serialization escapes &, <, > but NOT " or '. Call sites
|
||
// that interpolate escapeHtml output inside an attribute value (title="...",
|
||
// data-x="...") need those escaped too or an attacker-controlled value can
|
||
// break out of the attribute. Add both manually.
|
||
return div.innerHTML
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
// ─── SSE Connection ─────────────────────────────────────────────
|
||
|
||
// Fetch a view-only SSE session cookie before opening EventSource.
|
||
// EventSource can't send Authorization headers, and putting the root
|
||
// token in the URL (the old ?token= path) leaks it to logs, referer
|
||
// headers, and browser history. POST /sse-session issues an HttpOnly
|
||
// SameSite=Strict cookie scoped to SSE reads only; withCredentials:true
|
||
// on EventSource makes the browser send it back.
|
||
async function ensureSseSessionCookie() {
|
||
if (!serverUrl || !serverToken) return false;
|
||
try {
|
||
const resp = await fetch(`${serverUrl}/sse-session`, {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: { 'Authorization': `Bearer ${serverToken}` },
|
||
});
|
||
return resp.ok;
|
||
} catch (err) {
|
||
console.warn('[gstack sidebar] Failed to mint SSE session cookie:', err && err.message);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function connectSSE() {
|
||
if (!serverUrl) return;
|
||
if (eventSource) { eventSource.close(); eventSource = null; }
|
||
|
||
await ensureSseSessionCookie();
|
||
const url = `${serverUrl}/activity/stream?after=${lastId}`;
|
||
eventSource = new EventSource(url, { withCredentials: true });
|
||
|
||
eventSource.addEventListener('activity', (e) => {
|
||
try { addEntry(JSON.parse(e.data)); } catch (err) {
|
||
console.error('[gstack sidebar] Failed to parse activity event:', err.message);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener('gap', (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
const feed = document.getElementById('activity-feed');
|
||
const banner = document.createElement('div');
|
||
banner.className = 'gap-banner';
|
||
banner.textContent = `Missed ${data.availableFrom - data.gapFrom} events`;
|
||
feed.appendChild(banner);
|
||
} catch (err) {
|
||
console.error('[gstack sidebar] Failed to parse gap event:', err.message);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ─── 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. Replaces the old `sidebar-command` route which
|
||
// spawned a one-shot claude -p (sidebar-agent.ts is gone).
|
||
//
|
||
// Pre-scan via /pty-inject-scan before injection (D6, closes #1370).
|
||
// gstackScanForPTYInject is async; gstackInjectToTerminal stays sync.
|
||
const verdict = await window.gstackScanForPTYInject?.(message + '\n', 'inspector-send');
|
||
if (verdict?.verdict === 'BLOCK') {
|
||
console.warn('[gstack sidebar] Inspector send BLOCKED by /pty-inject-scan:', verdict.reasons);
|
||
return;
|
||
}
|
||
if (verdict?.verdict === 'WARN') {
|
||
const confirmed = window.confirm(
|
||
`Inspector send flagged as suspicious (${(verdict.reasons || []).join(', ')}). Inject anyway?`,
|
||
);
|
||
if (!confirmed) return;
|
||
}
|
||
const ok = window.gstackInjectToTerminal?.(message + '\n');
|
||
if (!ok) {
|
||
console.warn('[gstack sidebar] Inspector send needs an active Terminal session.');
|
||
}
|
||
});
|
||
|
||
// ─── Quick Action Helpers (toolbar buttons) ──────────────────────
|
||
|
||
/**
|
||
* "Cleanup" injects a prompt into the running claude PTY. claude takes the
|
||
* prompt, snapshots the page, hides ads/banners/popups, leaves article
|
||
* content. The user watches it happen in the Terminal pane.
|
||
*
|
||
* Replaced the old chat-queue path (sidebar-agent.ts spawning a one-shot
|
||
* claude -p) — we have a live REPL now, so route through that instead.
|
||
*/
|
||
async function runCleanup(...buttons) {
|
||
buttons.forEach(b => b?.classList.add('loading'));
|
||
const cleanupPrompt = [
|
||
'Clean up the active browser page for reading. Run:',
|
||
'$B cleanup --all',
|
||
'then $B snapshot -i, identify any remaining ads, cookie/consent banners,',
|
||
'newsletter popups, login walls, video autoplay, sidebar widgets, share',
|
||
'buttons, floating chat widgets, and hide each via $B eval. Keep the site',
|
||
'header/masthead, headline, article body, images, byline, and date. Also',
|
||
'unlock scrolling if the page is scroll-locked.',
|
||
].join('\n');
|
||
// Pre-scan via /pty-inject-scan before injection (D6, closes #1370).
|
||
// The cleanup prompt is a STATIC template (no page-derived content), so
|
||
// it will always PASS, but we still route it through the scan path so
|
||
// the invariant test in test/extension-pty-inject-invariant.test.ts
|
||
// confirms every call site goes through gstackScanForPTYInject first.
|
||
const verdict = await window.gstackScanForPTYInject?.(cleanupPrompt + '\n', 'cleanup-button');
|
||
if (verdict?.verdict === 'BLOCK') {
|
||
console.warn('[gstack sidebar] Cleanup BLOCKED by /pty-inject-scan:', verdict.reasons);
|
||
setTimeout(() => buttons.forEach(b => b?.classList.remove('loading')), 200);
|
||
return;
|
||
}
|
||
if (verdict?.verdict === 'WARN') {
|
||
const confirmed = window.confirm(
|
||
`Cleanup flagged as suspicious (${(verdict.reasons || []).join(', ')}). Inject anyway?`,
|
||
);
|
||
if (!confirmed) {
|
||
setTimeout(() => buttons.forEach(b => b?.classList.remove('loading')), 200);
|
||
return;
|
||
}
|
||
}
|
||
const sent = window.gstackInjectToTerminal?.(cleanupPrompt + '\n');
|
||
if (!sent) {
|
||
console.warn('[gstack sidebar] Cleanup needs an active Terminal session.');
|
||
}
|
||
setTimeout(() => buttons.forEach(b => b?.classList.remove('loading')), 1200);
|
||
}
|
||
|
||
async function runScreenshot(...buttons) {
|
||
if (!serverUrl || !serverToken) return;
|
||
buttons.forEach(b => b?.classList.add('loading'));
|
||
try {
|
||
const resp = await fetch(`${serverUrl}/command`, {
|
||
method: 'POST',
|
||
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ command: 'screenshot', args: [] }),
|
||
signal: AbortSignal.timeout(15000),
|
||
});
|
||
const text = await resp.text();
|
||
if (!resp.ok) {
|
||
console.warn('[gstack sidebar] Screenshot failed:', text);
|
||
} else {
|
||
console.log('[gstack sidebar] Screenshot:', text);
|
||
}
|
||
} catch (err) {
|
||
console.error('[gstack sidebar] Screenshot error:', err.message);
|
||
} finally {
|
||
buttons.forEach(b => b?.classList.remove('loading'));
|
||
}
|
||
}
|
||
|
||
// ─── Wire up all cleanup/screenshot buttons (inspector + chat toolbar) ──
|
||
|
||
const inspectorCleanupBtn = document.getElementById('inspector-cleanup-btn');
|
||
const inspectorScreenshotBtn = document.getElementById('inspector-screenshot-btn');
|
||
const chatCleanupBtn = document.getElementById('chat-cleanup-btn');
|
||
const chatScreenshotBtn = document.getElementById('chat-screenshot-btn');
|
||
|
||
if (inspectorCleanupBtn) inspectorCleanupBtn.addEventListener('click', () => runCleanup(inspectorCleanupBtn, chatCleanupBtn));
|
||
if (inspectorScreenshotBtn) inspectorScreenshotBtn.addEventListener('click', () => runScreenshot(inspectorScreenshotBtn, chatScreenshotBtn));
|
||
if (chatCleanupBtn) chatCleanupBtn.addEventListener('click', () => runCleanup(chatCleanupBtn, inspectorCleanupBtn));
|
||
if (chatScreenshotBtn) chatScreenshotBtn.addEventListener('click', () => runScreenshot(chatScreenshotBtn, inspectorScreenshotBtn));
|
||
|
||
// ─── Section Toggles ────────────────────────────────────────────
|
||
|
||
document.querySelectorAll('.inspector-section-toggle').forEach(toggle => {
|
||
toggle.addEventListener('click', () => {
|
||
const section = toggle.dataset.section;
|
||
const body = document.getElementById(`inspector-${section}`);
|
||
const isCollapsed = toggle.classList.contains('collapsed');
|
||
|
||
toggle.classList.toggle('collapsed', !isCollapsed);
|
||
toggle.setAttribute('aria-expanded', isCollapsed);
|
||
toggle.querySelector('.inspector-toggle-arrow').innerHTML = isCollapsed ? '▼' : '▶';
|
||
body.classList.toggle('collapsed', !isCollapsed);
|
||
});
|
||
});
|
||
|
||
// ─── Inspector SSE ──────────────────────────────────────────────
|
||
|
||
async function connectInspectorSSE() {
|
||
if (!serverUrl || !serverToken) return;
|
||
if (inspectorSSE) { inspectorSSE.close(); inspectorSSE = null; }
|
||
|
||
// Same session-cookie pattern as connectSSE. ?token= is gone (see N1
|
||
// in the v1.6.0.0 security wave plan).
|
||
await ensureSseSessionCookie();
|
||
const url = `${serverUrl}/inspector/events?_=${Date.now()}`;
|
||
|
||
try {
|
||
inspectorSSE = new EventSource(url, { withCredentials: true });
|
||
|
||
inspectorSSE.addEventListener('inspectResult', (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
inspectorShowData(data);
|
||
} catch (err) {
|
||
console.error('[gstack sidebar] Failed to parse inspectResult:', err.message);
|
||
}
|
||
});
|
||
|
||
inspectorSSE.addEventListener('error', () => {
|
||
// SSE connection failed — inspector works without it (basic mode)
|
||
if (inspectorSSE) { inspectorSSE.close(); inspectorSSE = null; }
|
||
});
|
||
} catch (err) {
|
||
console.debug('[gstack sidebar] Inspector SSE not available:', err.message);
|
||
}
|
||
}
|
||
|
||
// ─── Server Discovery ───────────────────────────────────────────
|
||
|
||
function setActionButtonsEnabled(enabled) {
|
||
const btns = document.querySelectorAll('.quick-action-btn, .inspector-action-btn');
|
||
btns.forEach(btn => {
|
||
btn.disabled = !enabled;
|
||
btn.classList.toggle('disabled', !enabled);
|
||
});
|
||
}
|
||
|
||
function updateConnection(url, token) {
|
||
const wasConnected = !!serverUrl;
|
||
serverUrl = url;
|
||
serverToken = token || null;
|
||
// Expose for sidepanel-terminal.js (PTY surface). The terminal pane needs
|
||
// the bootstrap token to POST /pty-session and the port to derive the WS
|
||
// URL. We never expose the PTY token — it lives in an HttpOnly cookie.
|
||
if (url) {
|
||
try { window.gstackServerPort = parseInt(new URL(url).port, 10); } catch {}
|
||
window.gstackAuthToken = token || null;
|
||
} else {
|
||
window.gstackServerPort = null;
|
||
window.gstackAuthToken = null;
|
||
}
|
||
if (url) {
|
||
document.getElementById('footer-dot').className = 'dot connected';
|
||
const port = new URL(url).port;
|
||
document.getElementById('footer-port').textContent = `:${port}`;
|
||
setConnState('connected');
|
||
setActionButtonsEnabled(true);
|
||
// Tell the active tab's content script the sidebar is open — this hides
|
||
// the welcome page arrow hint. Only fires on actual sidebar connection.
|
||
chrome.runtime.sendMessage({ type: 'sidebarOpened' }).catch(() => {});
|
||
connectSSE();
|
||
connectInspectorSSE();
|
||
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.
|
||
}
|
||
});
|