mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
+22




![dependabot[bot]](/assets/img/avatar_default.png)
![capy-ai[bot]](/assets/img/avatar_default.png)



Garry Tan
Jim McKeeth
thearbajonart
capy-ai[bot]
dependabot[bot]
DanCanadian
Nader Helmy
Nader Helmy
Zee Zheng
Jack Thomas
Jayesh Betala
techcenter68
Mattias Petersson
Arno V
0xDevNinja
Benjamin D. Smith
Brian Majewski
neallee2012
Max Hogan
maxpetrusenkoagent
katlun-lgtm
mikehasa
Briac
JackJin
EmilianoU26
frosimanuel
Tom Lam
Yotam
Jinzhe
Xianying Tan
Boyard Engels
Jey2311
8bb6d65270
* docs: fix CONTRIBUTING.md test-tiers link in TESTING_INTERNALS The link resolved relative to docs/, pointing at a nonexistent docs/CONTRIBUTING.md. Adopts #3070. Co-authored-by: thearbajonart <334330110+thearbajonart@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ci: refresh GitHub Actions SHA pins (setup-buildx 4.4.1, build-push 7.4.0, attest 4.2.2, setup-node 7.0.0) Rewrite of dependabot #2988. Every bumped `uses:` stays pinned to the full commit SHA (verified against the upstream tags) and now carries its exact version comment, including the lines that had none. setup-node 6.5.0 -> 7.0.0 is a major bump; its notes list an ESM migration, cache outputs and removal of a dummy NODE_AUTH_TOKEN export, none of which affect our node-version-only use. actionlint 1.7.11 (CI image) and 1.7.12 are clean. Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(openclaw): stop advising a per-spawn env for spawned sessions OpenClaw's sessions_spawn has no env argument (openclaw/openclaw#51654 was closed without adding one), so the documented env: { OPENCLAW_SESSION: "1" } silently did nothing. Point users at the ACP harness or Gateway environment instead and guard the old advice. Adopts #2749. Co-authored-by: DanCanadian <211164803+DanCanadian@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(benchmark): scope /benchmark routing to web page performance /benchmark claimed the bare words "benchmark" and "performance", so model and eval benchmark requests could route to the web performance skill instead of /benchmark-models. Narrow the description, Use-when list and triggers to web-specific phrases, point model/skill benchmarks at /benchmark-models, and split the root router line the same way. The catalog lead stays within the discovery budget ("Web performance ..."). Adopts #2302 and closes duplicate #2083 (rewritten on current main). Co-authored-by: Nader Helmy <31414549+creatornader@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(llms.txt): link skills by generated path, drop host-skipped skills gen-llms-txt built every skill link as <name>/SKILL.md, so the router pointed at a nonexistent gstack/SKILL.md (it is the root SKILL.md) and /claude-code pointed at a SKILL.md the Claude host never generates. Links now use the template's generated output path, and skills the Claude host skips are left out of the index. Adopts #2279. Co-authored-by: Nader Helmy <creator.nader@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ci): install Bun from the SHA-256-verified release archive in GitLab CI and Ubicloud setup Rewrite of #1713. .gitlab-ci.yml's .setup-bun anchor and scripts/ubicloud/setup-free-suite.sh still piped https://bun.sh/install into bash. Both now download the bun-v$BUN_VERSION linux-x64 release zip, check it against the same SHA-256 Dockerfile.ci uses (#1706) before unzipping, install it to ~/.bun/bin and assert `bun --version`. test/ci-image-cli-pin.test.ts runs curlPipedToShell over both files and binds their hash and BUN_VERSION to Dockerfile.ci's amd64 arm and package.json's engines floor, so a Bun bump has to move all of them together. Verified by running the GitLab anchor in debian:stable-slim (bash and sh) and the Ubicloud block in a temp HOME. Co-authored-by: Zee Zheng <228040220+spacegeologist@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(browse): viewport auto unpins a pinned viewport without a restart `viewport WxH` pinned the page with no way back short of `browse restart`, which also drops a headed /connect-chrome session. `viewport auto` (aliases `reset`, `unpin`) now undoes the pin: - headed: the context already launches with `viewport: null`, but Playwright cannot drop a page's emulated size (and re-applies it on new frame sessions), so the active tab gets a fresh page in the same context at the same URL or load-html content. Cookies and storage persist, the tab id and ownership stay, refs reset as on navigation, and the page follows the real window again. - headless: back to the 1280x720 launch default, tracked viewport included. `auto` with --scale is rejected. Fixes #1059. Adopts #2782 and #1881. Co-authored-by: Jack Thomas <17882510+loulanyue@users.noreply.github.com> Co-authored-by: Jayesh Betala <jayesh.betala7@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(browse): screenshot accepts --clip=x,y,w,h and --selector=<css> Agent-generated commands often use the GNU `--flag=value` form, which threw "Unknown screenshot flag". Both value flags now accept it, through the same validation and conflict checks as the space-separated form. Adopts #1563. Co-authored-by: techcenter68 <75930234+techcenter68@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(benchmark): price Codex cached input at the cache rate, stop calling Gemini OAuth ready Codex reports cached_input_tokens as a subset of input_tokens, but the adapter billed all of input_tokens at the full input rate. Split them into disjoint uncached and cached counts (what estimateCostUsd expects) and show new+cached input in the table and Markdown reports. A stored Gemini OAuth file alone is now reported as not ready, with a reason naming GEMINI_API_KEY; key-based auth stays ready. Adopts the focused subset of #2283; the capacity error code and stdin change are left out (no captured failure or reproduced hang). Co-authored-by: Mattias Petersson <mattias.petersson@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): catch loosened-input consumers and stale user-facing strings The Enum & Value Completeness check fired only on newly added values, so a diff that widens what a validator, parser or guard accepts, and breaks an unchanged downstream consumer outside the diff, was a blind spot. Widen the check's trigger (heading unchanged) and add a consumer-grep bullet. Teach the maintainability specialist to flag user-facing strings whose guarding condition changed while the text did not. Adopts #2141 (rewritten on current main; the section heading is kept). Co-authored-by: Arno V <38716+aversini@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(global-discover): bucket Codex sessions by originator Every Codex rollout counted as one undifferentiated codex bucket, so /retro global could not tell interactive Codex Desktop work from codex_exec runs or Claude Code-driven calls. Normalize session_meta.payload.originator into desktop, exec, claude_code or other (missing, unknown and non-string values), report the split per repo as codex_originators and globally as tools.codex.originators, and print it in the summary. Existing totals are unchanged. Adopts #1488. Refs #1315. Co-authored-by: 0xDevNinja <manmit0x@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(hook-check): cover skill frontmatter hooks, sourced helpers, conflict markers and the caller's tsconfig Rewrite of #3066. bin/gstack-hook-check only listed setup's hooks plus /autoplan's by name, so the /careful and /freeze PreToolUse checks (also run by /guard and /investigate), /plan-ceo-review's hand-off hook and the helpers they source were never parse-checked before Claude Code ran them. - The hook list is setup's _hook_command_path arguments plus every gstack path on a `command:` line in a generated */SKILL.md frontmatter (9 hooks, up from 6). The generated files are read rather than the .tmpl sources because the /autoplan and /plan-ceo-review commands come from resolvers. - Each shell hook's gstack-tree helpers (`.`/`source` lines, assignments and shellcheck source= directives, followed transitively) are parsed too: careful/bin/hook-extract.sh and bin/gstack-state-root.sh for /careful and /freeze. - The shim, its helpers, each TypeScript entry and every local module the bundle used (bun build --metafile; entry only when bun lacks it) are scanned for conflict-marker lines, which still parse inside heredocs, strings and template literals. - bun build runs from the gstack root, so the caller's broken tsconfig.json or bunfig.toml no longer fails every TypeScript hook. Lists are newline separated, so a root with spaces works. Tests in test/setup-hook-syntax-gate.test.ts cover each case; the gstack-doctor fixture now copies autoplan/SKILL.md, whose frontmatter registers the hook its hooks-row test breaks. docs/troubleshooting.md describes the wider check. Co-authored-by: Benjamin D. Smith <7134745+BenjaminDSmithy@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): extend defensive-security framing to the security specialist and Red Team The "authorized defensive-security review" framing and fixture summary mode lived only in the adversarial subagent prompt. The security specialist and the Red Team also reason adversarially over the full diff, so repos whose test corpus is attack payloads could still get those passes refused. Move both paragraphs into scripts/resolvers/defensive-review.ts (the adversarial render stays byte-identical), state them once in the specialist dispatch for the security specialist and the Red Team, and have the Red Team prompt start from them. Testing and the other specialists keep full fixture reads; the codex host still strips Review Army. Parity caps for ship and review rise by the measured 1,380 bytes each (carve-guards notes). Adopts #1921 (rewritten on current main). Co-authored-by: Brian Majewski <1693720+bmajewski@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(team-init): make the required-mode hook shell-neutral (node CommonJS, no shell variables) Rewrite of #2217. Required team mode wrote a bash check-gstack.sh and registered it as "$CLAUDE_PROJECT_DIR/.claude/hooks/check-gstack.sh", which only works when the hook runner is a POSIX shell. Under cmd.exe or PowerShell (Windows, Copilot CLI) the variable is not expanded and the .sh cannot run, so an intended allow or deny became a hook error. - The hook is now .claude/hooks/check-gstack.cjs: the same root search (GSTACK_ROOT, every host's global root, the migrated repo), with the home directory from HOME, then USERPROFILE, then os.homedir(). It prints {} when gstack resolves and the PreToolUse deny JSON with exit 2 when not. - The registered command is `node -e "require(require('path').resolve( process.env.CLAUDE_PROJECT_DIR || '.', '.claude', 'hooks', 'check-gstack.cjs'))"`: no `$` or `%`, so sh, cmd.exe and PowerShell all pass it through unchanged. - An existing check-gstack registration (matcher Skill or Skill|skill) is replaced in place; duplicates are dropped and unrelated hooks in the same entry kept. The legacy .sh is deleted only after the settings write succeeds (a tracked one joins the suggested git add), and is kept when bun is missing or the write fails, which now reports instead of claiming success. Tests: team-mode and the hook-schema test run the command as registered; test/team-init-hook-shell-neutral.test.ts runs it through the platform's default shell (cmd.exe on the Windows lane, which curates it in) and through PowerShell when installed (verified here in the pwsh 7 container). The routing-probe check for the bash `_GSTACK_ROOT` gate now pins the same contract in the .cjs: every host root probed, block only when none resolves. Co-authored-by: neallee2012 <1499497+neallee2012@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(setup-gbrain): register the gbrain MCP through bun.exe on Windows so no console window opens Rewrite of #2007, folding #1963's MCP notes. On Windows the local stdio registration stored the bun `gbrain` binstub; gbrain.exe starts bun in a new console, so every Claude Code session opened a visible terminal for the MCP (the contributor's process trees show it; bun.exe launched directly attaches to Claude Code's headless ConPTY). Under MINGW/MSYS/Cygwin the skill now resolves BUN_ROOT from BUN_INSTALL or ~/.bun and registers `$BUN_ROOT/bin/bun.exe <global node_modules gbrain/src/cli.ts> serve`. That path also covers a `bun link`ed clone, whose global node_modules entry links to it; gstack-gbrain-install records no other clone location. When bun.exe or the entry is missing it registers `$BUN_ROOT/bin/gbrain.exe` by its full name, because Claude Code does not apply PATHEXT to a stored path (#1963). POSIX keeps the binstub line. The skill also notes that `claude mcp list` can show "Failed to connect" while a live session holds a local PGLite brain's single-writer lock (#1963). test/setup-gbrain-windows-mcp.test.ts runs the generated fence under bash with stub uname/claude and a temp HOME for MINGW64, MSYS with BUN_INSTALL, a missing entry, and Linux. It is listed as Windows-incompatible (POSIX stubs); the console behavior needs a manual Windows check. Co-authored-by: Max Hogan <max@maxhogan.dev> Co-authored-by: maxpetrusenkoagent <280464918+maxpetrusenkoagent@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(hosts): rewrite every "Agent tool" phrasing for agent-runtime hosts The Agent-tool rewrite for hermes, gbrain, openclaw and factory keyed only on "use the Agent tool", but templates mostly say "via the Agent tool", "with the Agent tool", "multiple Agent tool calls" or "Use the Agent tool", so the Claude tool name leaked into those hosts' skills and sections. Add ordered rules after the existing key (possessive, "the Agent tool", then bare "Agent tool") mapping to sessions_spawn (gbrain/openclaw), delegate_task (hermes) and delegation (factory). No goldens change. Adopts #1935 (rewritten on current main, without the new goldens). Co-authored-by: katlun-lgtm <264247399+katlun-lgtm@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(setup-deploy): Vercel auto-deploys only when connected to the Git repo /setup-deploy stated that Vercel always deploys on push, which is false for CLI-only projects with no Git integration, and its nothing-detected path did not say why such a project is invisible to detection. Qualify the Vercel trigger (ask for and record the CLI deploy command otherwise) and note that `vercel link` creates .vercel/ so a re-run detects the project. Adopts the Vercel accuracy part of #2992 (rewritten on current main); the third-party bring-up tool pointer is not adopted. Co-authored-by: mikehasa <91774998+mikehasa@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(retro): persist recommendations so the next retro detects follow-through /retro produced "3 Things to Improve" but never saved them in its JSON snapshot, so the next retro could not tell whether last week's recommendations were acted on. Step 13 now drafts the items before saving and always writes a `recommendations` array ({category, text}, [] when empty); Step 12 scores each prior recommendation addressed/partial/open against this window's commits, files and metrics and renders a follow-through line, skipping silently for snapshots without the key. The report format ties the items to the saved array. Global mode is unchanged. Adopts #1853 (rewritten on current main). Refs #1834. Co-authored-by: Jayesh Betala <jayesh.betala7@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(global-discover): count the interactive Codex CLI (codex_cli_rs) in its own cli bucket * feat(developer-profile): --reconcile backfills office-hours tenure from timeline.jsonl sessions[] is written only at office-hours Phase 4.5, so runs that ended earlier never counted toward tenure and the tier undercounted. Add a manual `--reconcile` that scans projects/*/timeline.jsonl for office-hours `completed` events and appends a count-only row ({date, mode, project_slug, signal_count: 0, signals: [], backfilled: true}) for each run with no sessions[] entry for the same project within 60 minutes, extended back by a duration_s under 8h. It is idempotent, skips malformed lines, and leaves the file untouched on a no-op. `--read` counts backfilled rows for SESSION_COUNT and TIER but takes LAST_*, DESIGN_*, CROSS_PROJECT and the nudge from logged sessions only. Not wired into the preamble yet; the office-hours greeting guard for backfill-only profiles is left to the office-hours owner. Adopts #2668 (rewritten on current main). Closes part (b) of #2657. Co-authored-by: Briac <137724549+briacSck@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): the publication hook approves /autoplan's own Reads after the skill turn Claude Code applies a skill's allowed-tools only to the turn that invoked it. On 2.1.292 the default fork-subagent mode runs reviewers in the background, and the completion notice starts a new turn, so later Reads outside the working directory (the next phase's section file, the close packet) raised permission cards. Skill hooks stay registered for the session, and a PreToolUse "allow" skips the card (reproduced in a PTY on 2.1.292). autoplan/bin/owned-read.ts approves a parent Read only when the guard itself returned no output, and only for this installation's autoplan/sections/*.md (canonical path or the gbrain :user render) or an immutable phase artifact in <repo>/.gstack/tmp/autoplan/autoplan-<phase>-*/ whose repo is the session's project directory or contains it. Denials, unverified allows, child calls and every other path are unchanged. renderSectionBase and userRenderRoot move there with it. Tests that asserted a verified driver Read as {} now expect the approval; autoplan-owned-read covers the shipped shim, the close packet without a journal, verified artifacts through the guard, look-alikes (foreign install, writable copy, symlink alias, other repository, other names) and a guard denial staying a denial. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): restore point and phase artifacts live in the project's .gstack/tmp/autoplan/ Skill hooks do not run inside subagents, so a reviewer launched after a background completion notice raised a card for its own input (native-prompt.md under the state root). Step 1 now puts RESTORE_PATH in the repository's git-excluded .gstack/tmp/autoplan/ (added to .git/info/exclude, mode 700), and the snapshot tool writes every phase artifact beside it, so /autoplan and its reviewers read them from the working directory without a card. The guard's bindings are unchanged: they anchor on the restore point's directory. Step 1 still prints the slug, which the old RESTORE_PATH carried. A free test runs the generated Step 1 block twice from a repository subdirectory: the restore point lands in <repo>/.gstack/tmp/autoplan/, the exclude line is written once and git status stays clean. The autoplan parity cap rises by the measured bytes (115,270 / 101,979 = 1.1303). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(evals): autoplan-guard-pty fails on any permission card after the reviewer's notice turn The scripted boundary now mirrors Step 1's layout (restore point and artifacts in the project's .gstack/tmp/autoplan/) and continues past the Phase 2 entry to dispatch the Phase 2 reviewer after the CEO reviewer's completion notice. The case asserts four verified guard allows, that the close packet Read, the Phase 2 section Read and the Phase 2 dispatch came after a task-notification turn, that the Phase 2 reviewer read its input, and that no permission card appeared at all. Still one trial on haiku, about 75-85 seconds. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(autoplan): permission cards after a background reviewer, and where /autoplan keeps its files Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(outside-review): read a trailing '— Medium.' or '(High)' severity on a finding line, the shape Codex now writes * feat(design): honor OPENAI_BASE_URL for every design API call One resolver, openaiUrl() in design/src/auth.ts, now builds every OpenAI URL the design binary, its prototype and its live model check use. Setting OPENAI_BASE_URL sends image generation and screenshot analysis to an OpenAI-compatible gateway; unset or blank keeps api.openai.com. The prototype script now goes through receiptedFetch, so its egress is receipted like the rest, and receipts already record the gateway host. A source scan keeps any design file outside auth.ts from hardcoding an OpenAI API URL again, and a request-shape test proves the gateway reaches both the vision and image endpoints. Co-authored-by: JackJin <1037461232@qq.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review: testing specialist treats negative assertions as decisions A new assertion that something is absent records a behavior removal and needs cited intent. A rename or removal in the diff can empty a standing negative assertion so it passes forever; the specialist now greps the suite for negative assertions naming the old value. Adopted from the testing-specialist part of a community PR; the scope-check hunk targeted text that no longer exists. Co-authored-by: EmilianoU26 <314541067+EmilianoU26@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review: security specialist checks authorization a route-guard read misses Four access-control classes where the guard is correct and the object still leaks: error pages composed outside the guard, ownership surviving a role downgrade, list/detail/download paths checked as one, and a session mutated before the identity-switch callback is validated. Adopted to review/specialists/security.md only; the /cso hunk targeted a bullet list that is now an OWASP/ASVS table. Co-authored-by: EmilianoU26 <314541067+EmilianoU26@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * review: zero findings is valid; suppress six common false positives The checklist now says a clean diff may come back clean, and suppresses framework-owned error handling, long exhaustive switches, null derefs a type guard already narrows, N+1 on fixed-cardinality loops, hardcoded test fixture values and non-cryptographic Math.random(). The contributor's "missing input validation on an internal function" and "missing await on fire-and-forget" suppressions are left out: both can hide real bugs. Co-authored-by: frosimanuel <frosimanuel@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(todos): parked contributor ideas by theme; /autoplan permission follow-ups * design-review: capture code tokens before universal principles /design-review read only DESIGN.md and otherwise fell back to universal principles, so a project whose system lives in CSS custom properties, tailwind.config or a theme file got audited and mocked up against an invented aesthetic. The skill now reads code tokens before falling back, and the target-mockup brief reuses the captured colors, fonts, radii and spacing, changing layout only. The contributor's optional microcopy checks (domain vocabulary, human register) are not adopted here. Co-authored-by: Tom Lam <tomlam0811@berkeley.edu> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(sidebar): add a terminal text-size picker to the toolbar The sidebar terminal toolbar gets an XS-XL text-size picker beside Restart. SM (13px) stays the default, so nothing changes until a user picks a size; the choice persists in chrome.storage.local and applies to the live terminal, re-fitting xterm and re-sending the PTY size two frames later so the new character cell is measured first. A VM test runs the real sidepanel-terminal.js through one auto-connect and checks the creation size, the stored-size fallback, persistence and the live resize. Co-authored-by: Yotam <yotsurfing@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(design-shotgun): generate with Codex's built-in $imagegen on Codex On the Codex host, /design-shotgun now generates and edits mockups with Codex's documented $imagegen skill in its default built-in mode (the image_gen tool), so users signed in with ChatGPT need no OPENAI_API_KEY. The design binary stays for the local comparison board only, images are published with gstack-design-claim as before, and screenshot evolution loads the captured page with view_image before an $imagegen edit. Step 3c-3d moved into a DESIGN_SHOTGUN_GENERATION resolver that returns the existing design-binary text unchanged for every other host; the Claude output differs only in two host-neutral rewordings of the evolve-from-screenshot pointers. DESIGN_SETUP and the regenerate step of the board loop gain matching Codex-only text for design-shotgun. Co-authored-by: Jinzhe <hwang.phy@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * voice: ban the "load-bearing" stock phrase Models overuse "load-bearing"; it joins the AI-vocabulary ban line in both voice tiers. The tier-2 voice directive is also tightened (the meta "compressed for runtime" tag and the bounded closer's restated exemption clause) so the shared preamble shrinks by 163 bytes per tier-2+ skill, which also pays for the reply-language rule that follows. The ship goldens are refreshed for the new render. Co-authored-by: Nader Helmy <creator.nader@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * voice: reply in the language of the user's latest message Every skill's voice directive now tells the agent to reply in the language of the user's latest message unless asked otherwise, so long English skill text no longer drags a Chinese (or any non-English) session into English. Mixed-language sessions follow the latest message. Code, commands, paths, identifiers and quoted output stay verbatim, and at tier 2+ so do the machine-read question markers (D<N>, option letters, (recommended)). The rule costs 205 bytes per tier-2+ skill and 144 per tier-1 skill. A further 25-byte cut to the bounded closer's exemption list leaves the net for this wave at +17 bytes per tier-2+ skill and +158 per tier-1 skill. review, qa and browse caps rise with measured notes; the ship goldens are refreshed. Co-authored-by: Xianying Tan <shrektan@126.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(redact-prepush): committed exact-span allowlist at .gstack-redact-allowlist The pre-push hook never fed the engine's allowlist option, so a reviewed, known-benign value (a public dev-compose URL, a documented sample key) warned or blocked every push, and the only ways out were a bypass or reshaping code until the detector stopped matching. The hook now reads the repo-root .gstack-redact-allowlist from each pushed commit (never the working tree), one exact matched span per line with # comments. Only a finding whose matched text equals an entry is suppressed: a password alone, a substring, or a different key still reports and still blocks, and engine.* unscanned findings are untouched. Every push that carries entries prints the entry count and each suppressed finding's rule and file line. Oversized files (over 64 KiB) are ignored. gstack.redact.allowEmail keeps working as before. Co-authored-by: Boyard Engels <boyard.engels@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(brain-sync): opt-in removal publishing with a bulk-removal valve gstack-brain-sync never published a removal: a deleted synced file, or the old directory after a project slug migration, stayed on the remote forever and gbrain indexed both copies. New setting artifacts_sync_removals (off by default). When on, each drain commits and pushes, receipted like every push, the removal of tracked files that are gone from disk and match the sync allowlist. Transcript pages are never removed here, and a decision log goes only together with its active snapshot. One valve: a drain that would remove more than 20 files at once publishes none and says so. `gstack-brain-sync --publish-removals` lists the pending removals and `--publish-removals --yes` publishes them on demand, whatever the setting. Co-authored-by: Jey2311 <5177215+Jey2311@users.noreply.github.com> * feat(windows): setup.ps1 checks prerequisites and runs ./setup in Git Bash A PowerShell entry point for Windows users. It checks that Git for Windows, Bun and Node.js are on PATH (printing the winget command for each missing one and changing nothing), finds Git Bash from git.exe rather than a PATH bash that may be WSL's, and runs ./setup from the checkout with every argument passed as its own argv entry to a constant bash script. No argument is interpolated into shell source. ./setup itself is unchanged. Co-authored-by: Jim McKeeth <jim@mckeeth.org> * v1.91.63.0: CHANGELOG, version * fix(ci): link bunx after the SHA-verified Bun install in GitLab CI and the Ubicloud setup * fix(redact-prepush): strip every parent column from combined-diff lines, so a merged release bump is exempt like any other * fix(hook-check): treat a Windows drive path as absolute (cygpath when present), so --list finds the skill hooks from a native caller * fix(windows): owned-read requires the read-only attribute on Windows; setup.ps1 passes arguments NUL-separated through a private file; Windows-only tests expect the owned-read approval * perf(hook-check): bundle every hook's TypeScript entries in one bun process instead of two bun starts per entry (seconds each on Windows) * perf(hook-check): read all skill frontmatter in one awk and scan conflict markers with one grep per hook (half the process starts) --------- Co-authored-by: thearbajonart <334330110+thearbajonart@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: DanCanadian <211164803+DanCanadian@users.noreply.github.com> Co-authored-by: Nader Helmy <31414549+creatornader@users.noreply.github.com> Co-authored-by: Nader Helmy <creator.nader@gmail.com> Co-authored-by: Zee Zheng <228040220+spacegeologist@users.noreply.github.com> Co-authored-by: Jack Thomas <17882510+loulanyue@users.noreply.github.com> Co-authored-by: Jayesh Betala <jayesh.betala7@gmail.com> Co-authored-by: techcenter68 <75930234+techcenter68@users.noreply.github.com> Co-authored-by: Mattias Petersson <mattias.petersson@gmail.com> Co-authored-by: Arno V <38716+aversini@users.noreply.github.com> Co-authored-by: 0xDevNinja <manmit0x@gmail.com> Co-authored-by: Benjamin D. Smith <7134745+BenjaminDSmithy@users.noreply.github.com> Co-authored-by: Brian Majewski <1693720+bmajewski@users.noreply.github.com> Co-authored-by: neallee2012 <1499497+neallee2012@users.noreply.github.com> Co-authored-by: Max Hogan <max@maxhogan.dev> Co-authored-by: maxpetrusenkoagent <280464918+maxpetrusenkoagent@users.noreply.github.com> Co-authored-by: katlun-lgtm <264247399+katlun-lgtm@users.noreply.github.com> Co-authored-by: mikehasa <91774998+mikehasa@users.noreply.github.com> Co-authored-by: Briac <137724549+briacSck@users.noreply.github.com> Co-authored-by: JackJin <1037461232@qq.com> Co-authored-by: EmilianoU26 <314541067+EmilianoU26@users.noreply.github.com> Co-authored-by: frosimanuel <frosimanuel@gmail.com> Co-authored-by: Tom Lam <tomlam0811@berkeley.edu> Co-authored-by: Yotam <yotsurfing@gmail.com> Co-authored-by: Jinzhe <hwang.phy@gmail.com> Co-authored-by: Xianying Tan <shrektan@126.com> Co-authored-by: Boyard Engels <boyard.engels@gmail.com> Co-authored-by: Jey2311 <5177215+Jey2311@users.noreply.github.com> Co-authored-by: Jim McKeeth <jim@mckeeth.org>
1045 lines
40 KiB
JavaScript
1045 lines
40 KiB
JavaScript
/**
|
|
* Terminal sidebar tab — interactive Claude Code PTY in xterm.js.
|
|
*
|
|
* Lifecycle (per plan + codex review):
|
|
* 1. Sidebar opens. Terminal is the default-active tab.
|
|
* 2. Bootstrap card shows "Press any key to start Claude Code."
|
|
* 3. On first keystroke (lazy spawn — codex finding #8): the extension
|
|
* a) POSTs /pty-session on the browse server with the AUTH_TOKEN to
|
|
* mint a short-lived HttpOnly cookie scoped to the terminal-agent.
|
|
* b) Opens ws://127.0.0.1:<terminalPort>/ws — the cookie travels
|
|
* automatically. Terminal-agent validates the cookie + the
|
|
* chrome-extension:// Origin (codex finding #9), then spawns
|
|
* claude in a PTY.
|
|
* 4. Bytes pump both ways. Resize observer sends {type:"resize"} text
|
|
* frames; tab-switch hooks send {type:"tabSwitch"} frames.
|
|
* 5. PTY exits or WS closes -> we show "Session ended" with a restart
|
|
* button. We do NOT auto-reconnect (codex finding #8: auto-reconnect
|
|
* = burn fresh claude session every time).
|
|
*
|
|
* Keep this file dependency-free. xterm.js + xterm-addon-fit are loaded
|
|
* via <script src> tags in sidepanel.html (window.Terminal, window.FitAddon).
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
const Terminal = window.Terminal;
|
|
const FitAddonModule = window.FitAddon;
|
|
if (!Terminal) {
|
|
console.error('[gstack terminal] xterm not loaded');
|
|
return;
|
|
}
|
|
|
|
const els = {
|
|
bootstrap: document.getElementById('terminal-bootstrap'),
|
|
bootstrapStatus: document.getElementById('terminal-bootstrap-status'),
|
|
installCard: document.getElementById('terminal-install-card'),
|
|
installRetry: document.getElementById('terminal-install-retry'),
|
|
mount: document.getElementById('terminal-mount'),
|
|
ended: document.getElementById('terminal-ended'),
|
|
restart: document.getElementById('terminal-restart'),
|
|
restartNow: document.getElementById('terminal-restart-now'),
|
|
fontSize: document.getElementById('terminal-fontsize'),
|
|
};
|
|
|
|
// Toolbar text-size picker. 'sm' (13px) stays the default; a picked size
|
|
// persists in chrome.storage.local.
|
|
const FONT_SIZES = { xs: 11, sm: 13, md: 15, lg: 17, xl: 20 };
|
|
const FONT_SIZE_KEY = 'terminalFontSize';
|
|
let fontSize = 'sm';
|
|
|
|
/** State machine. */
|
|
const STATE = {
|
|
IDLE: 'idle',
|
|
CONNECTING: 'connecting',
|
|
LIVE: 'live',
|
|
ENDED: 'ended',
|
|
NO_CLAUDE: 'no-claude',
|
|
RECONNECTING: 'reconnecting', // v1.44 Commit 3 — re-attach loop active
|
|
};
|
|
let state = STATE.IDLE;
|
|
|
|
let term = null;
|
|
let fitAddon = null;
|
|
let ws = null;
|
|
/**
|
|
* Sticky abort flag for tryAutoConnect's polling loop. Set when we
|
|
* receive an unrecoverable signal (auth invalid → 401, claude not
|
|
* found, fatal server error) so the loop doesn't keep retrying and
|
|
* spamming the user with the same failure message every 2s. Cleared
|
|
* by forceRestart() so the user can re-enter the polling loop after
|
|
* fixing whatever was wrong.
|
|
*/
|
|
let autoConnectAborted = false;
|
|
/**
|
|
* v1.44 session identity. The stable, non-secret sessionId minted by
|
|
* /pty-session and surfaced back via window.gstackPtySession so the
|
|
* sidepanel.js pagehide handler can sendBeacon /pty-dispose for THIS
|
|
* specific session. forceRestart sends this to /pty-restart so the
|
|
* server can scope the disposal to one terminal rather than all.
|
|
*/
|
|
let currentSessionId = null;
|
|
/**
|
|
* Commit 3 re-attach loop. Set true while a re-attach is in flight so
|
|
* concurrent ws.close events (e.g. user clicks Restart mid-reconnect)
|
|
* can short-circuit. Reset by every state transition out of RECONNECTING.
|
|
*/
|
|
let reattachInFlight = false;
|
|
/**
|
|
* Set true after a {type:"reattach-begin"} text frame and reset after
|
|
* the next binary frame is treated as replay payload. The flag is what
|
|
* lets the message handler distinguish "this binary is the scrollback
|
|
* replay, write RIS first to clear xterm" from "this is live PTY
|
|
* output, just feed it through."
|
|
*/
|
|
let nextBinaryIsReplay = false;
|
|
/**
|
|
* Re-attach backoff schedule (ms). 1s, 2s, 4s, 8s, then 8s steady until
|
|
* 60s total elapsed (Commit 3 detach window). If all attempts fail,
|
|
* fall through to ENDED state and the user clicks Restart for a fresh
|
|
* session.
|
|
*/
|
|
const REATTACH_BACKOFF_MS = [1000, 2000, 4000, 8000];
|
|
const REATTACH_WINDOW_MS = 60_000;
|
|
/**
|
|
* 25s client-side WS keepalive interval (v1.44+). Belt-and-suspenders with
|
|
* the server-side ping in terminal-agent.ts: server pings cover most
|
|
* idle-NAT cases, client keepalive frames also defend against Chromium's
|
|
* MV3-adjacent panel suspension heuristics that can pause our timers.
|
|
* Started on ws.open, cleared on ws.close. The agent silently accepts
|
|
* `{type:"keepalive"}` text frames.
|
|
*/
|
|
let keepaliveInterval = null;
|
|
const KEEPALIVE_INTERVAL_MS = 25000;
|
|
|
|
function show(el) { el.style.display = ''; }
|
|
function hide(el) { el.style.display = 'none'; }
|
|
|
|
function setState(next, opts = {}) {
|
|
state = next;
|
|
switch (next) {
|
|
case STATE.IDLE:
|
|
show(els.bootstrap);
|
|
hide(els.installCard);
|
|
hide(els.mount);
|
|
hide(els.ended);
|
|
els.bootstrapStatus.textContent = opts.message || 'Press any key to start Claude Code.';
|
|
break;
|
|
case STATE.CONNECTING:
|
|
show(els.bootstrap);
|
|
hide(els.installCard);
|
|
hide(els.mount);
|
|
hide(els.ended);
|
|
els.bootstrapStatus.textContent = 'Connecting...';
|
|
break;
|
|
case STATE.LIVE:
|
|
hide(els.bootstrap);
|
|
hide(els.installCard);
|
|
show(els.mount);
|
|
hide(els.ended);
|
|
break;
|
|
case STATE.ENDED:
|
|
hide(els.bootstrap);
|
|
hide(els.installCard);
|
|
hide(els.mount);
|
|
show(els.ended);
|
|
break;
|
|
case STATE.NO_CLAUDE:
|
|
show(els.bootstrap);
|
|
show(els.installCard);
|
|
hide(els.mount);
|
|
hide(els.ended);
|
|
els.bootstrapStatus.textContent = '';
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read auth + terminalPort from the server's /health. We don't fetch this
|
|
* here — sidepanel.js already polls /health for connection state and
|
|
* exposes the relevant fields on window.gstackHealth (set below in init()).
|
|
* If terminalPort is missing, the agent isn't ready yet.
|
|
*/
|
|
function getHealth() {
|
|
return window.gstackHealth || {};
|
|
}
|
|
|
|
function getServerPort() {
|
|
return window.gstackServerPort || null;
|
|
}
|
|
|
|
function getAuthToken() {
|
|
return window.gstackAuthToken || null;
|
|
}
|
|
|
|
/**
|
|
* POST /pty-session to mint a fresh terminal session. Returns
|
|
* { terminalPort, ptySessionToken, expiresAt } on success, or
|
|
* { error } on failure. The token rides on the WebSocket
|
|
* Sec-WebSocket-Protocol header, which is the only auth header
|
|
* the browser WebSocket API lets us set. The token is NOT persisted —
|
|
* each sidebar load mints a fresh one and discards it on close.
|
|
*/
|
|
async function mintSession() {
|
|
const serverPort = getServerPort();
|
|
const token = getAuthToken();
|
|
if (!serverPort || !token) {
|
|
return { error: 'browse server not ready' };
|
|
}
|
|
try {
|
|
const resp = await fetch(`http://127.0.0.1:${serverPort}/pty-session`, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${token}` },
|
|
credentials: 'include',
|
|
});
|
|
if (!resp.ok) {
|
|
const body = await resp.text().catch(() => '');
|
|
return { error: `${resp.status} ${body || resp.statusText}` };
|
|
}
|
|
return await resp.json();
|
|
} catch (err) {
|
|
return { error: err && err.message ? err.message : String(err) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Commit 3 — re-attach loop. Triggered by an unexpected WS close
|
|
* (anything other than the v1.44 intentional codes) while state was
|
|
* LIVE. Posts /pty-session/reattach with the current sessionId; on
|
|
* success opens a new WS, feeds the {type:"reattach-begin"} +
|
|
* replay-binary handshake from the agent into xterm.
|
|
*
|
|
* Backoff: 1s, 2s, 4s, 8s, then 8s steady. Total wall budget is the
|
|
* server's DETACH_WINDOW_MS (default 60s) — past that point the
|
|
* server has disposed our session and any re-attach attempt will
|
|
* return 410 Gone.
|
|
*
|
|
* Aborts on:
|
|
* - reattachInFlight transitions to false (user clicked Restart or
|
|
* navigated away)
|
|
* - 410 Gone from /pty-session/reattach (lease expired)
|
|
* - 401 (auth invalid)
|
|
* - REATTACH_WINDOW_MS elapsed
|
|
*/
|
|
function startReattachLoop(prevSessionId) {
|
|
if (!prevSessionId) {
|
|
setState(STATE.ENDED);
|
|
return;
|
|
}
|
|
const serverPort = getServerPort();
|
|
const authToken = getAuthToken();
|
|
if (!serverPort || !authToken) {
|
|
setState(STATE.ENDED);
|
|
return;
|
|
}
|
|
reattachInFlight = true;
|
|
setState(STATE.RECONNECTING);
|
|
const startedAt = Date.now();
|
|
let attempt = 0;
|
|
|
|
const tick = async () => {
|
|
if (!reattachInFlight) return;
|
|
if (Date.now() - startedAt > REATTACH_WINDOW_MS) {
|
|
reattachInFlight = false;
|
|
setState(STATE.ENDED);
|
|
return;
|
|
}
|
|
attempt += 1;
|
|
let resp;
|
|
try {
|
|
resp = await fetch(`http://127.0.0.1:${serverPort}/pty-session/reattach`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${authToken}`,
|
|
},
|
|
body: JSON.stringify({ sessionId: prevSessionId }),
|
|
credentials: 'include',
|
|
});
|
|
} catch (err) {
|
|
scheduleNextAttempt();
|
|
return;
|
|
}
|
|
if (resp.status === 410) {
|
|
// Server disposed the session — lease window closed. No point
|
|
// retrying; fall through so the user clicks Restart for a fresh
|
|
// session.
|
|
reattachInFlight = false;
|
|
setState(STATE.ENDED);
|
|
return;
|
|
}
|
|
if (resp.status === 401) {
|
|
reattachInFlight = false;
|
|
autoConnectAborted = true;
|
|
setState(STATE.IDLE, {
|
|
message: 'Auth invalid — reload the sidebar or restart your gstack session.',
|
|
});
|
|
return;
|
|
}
|
|
if (!resp.ok) {
|
|
scheduleNextAttempt();
|
|
return;
|
|
}
|
|
let body;
|
|
try { body = await resp.json(); } catch { body = null; }
|
|
if (!body || !body.terminalPort || !body.attachToken) {
|
|
scheduleNextAttempt();
|
|
return;
|
|
}
|
|
reattachInFlight = false;
|
|
openReattachWebSocket(body.terminalPort, body.attachToken, body.sessionId || prevSessionId);
|
|
};
|
|
|
|
const scheduleNextAttempt = () => {
|
|
const backoffIdx = Math.min(attempt - 1, REATTACH_BACKOFF_MS.length - 1);
|
|
const delay = REATTACH_BACKOFF_MS[backoffIdx] ?? 8000;
|
|
setTimeout(tick, delay);
|
|
};
|
|
|
|
tick();
|
|
}
|
|
|
|
/**
|
|
* Open the post-reattach WebSocket. Mostly a clone of connect()'s
|
|
* attach wiring but with the {type:"reattach-begin"} → RIS → binary
|
|
* replay handshake added. The xterm element is REUSED (not disposed) so
|
|
* the buffer flash is minimal — RIS clears it cleanly just before the
|
|
* replay arrives.
|
|
*/
|
|
function openReattachWebSocket(terminalPort, attachToken, sessionId) {
|
|
currentSessionId = sessionId || null;
|
|
try { window.gstackPtySession = currentSessionId; } catch {}
|
|
setState(STATE.LIVE);
|
|
ensureXterm();
|
|
nextBinaryIsReplay = false;
|
|
ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [`gstack-pty.${attachToken}`]);
|
|
ws.binaryType = 'arraybuffer';
|
|
|
|
ws.addEventListener('open', () => {
|
|
try {
|
|
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
} catch {}
|
|
if (keepaliveInterval) clearInterval(keepaliveInterval);
|
|
keepaliveInterval = setInterval(() => {
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
try { ws.send(JSON.stringify({ type: 'keepalive' })); } catch {}
|
|
}, KEEPALIVE_INTERVAL_MS);
|
|
});
|
|
|
|
ws.addEventListener('message', (ev) => {
|
|
if (typeof ev.data === 'string') {
|
|
try {
|
|
const msg = JSON.parse(ev.data);
|
|
if (msg.type === 'reattach-begin') {
|
|
// Clear xterm before the replay binary arrives — RIS (\x1bc)
|
|
// is a full hardware reset that flushes the buffer and
|
|
// resets all attributes. The server's replay starts with
|
|
// DECSTR + optional alt-screen re-enter for safety.
|
|
try { term.write('\x1bc'); } catch {}
|
|
nextBinaryIsReplay = true;
|
|
return;
|
|
}
|
|
if (msg.type === 'error' && msg.code === 'CLAUDE_NOT_FOUND') {
|
|
setState(STATE.NO_CLAUDE);
|
|
try { ws.close(); } catch {}
|
|
return;
|
|
}
|
|
if (msg.type === 'ping') {
|
|
try { ws.send(JSON.stringify({ type: 'pong', ts: msg.ts })); } catch {}
|
|
return;
|
|
}
|
|
} catch {}
|
|
return;
|
|
}
|
|
const buf = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : ev.data;
|
|
// First binary frame after reattach-begin is the replay payload;
|
|
// write it through unchanged (server already prefixed soft-reset).
|
|
// Subsequent binary frames are live PTY output.
|
|
term.write(buf);
|
|
if (nextBinaryIsReplay) nextBinaryIsReplay = false;
|
|
});
|
|
|
|
ws.addEventListener('close', (ev) => {
|
|
ws = null;
|
|
if (keepaliveInterval) {
|
|
clearInterval(keepaliveInterval);
|
|
keepaliveInterval = null;
|
|
}
|
|
// If THIS reattach WS also closes uncleanly, recurse into another
|
|
// re-attach loop with the SAME sessionId — the server may still
|
|
// be inside the detach window. The state check + sessionId guard
|
|
// prevent runaway recursion (ENDED short-circuits the next loop).
|
|
if (state !== STATE.LIVE) return;
|
|
const code = (ev && (ev.code ?? 1006)) || 1006;
|
|
const intentional = code === 1000 || code === 4001 || code === 4404;
|
|
if (intentional || !currentSessionId) {
|
|
setState(intentional ? STATE.ENDED : STATE.ENDED);
|
|
return;
|
|
}
|
|
startReattachLoop(currentSessionId);
|
|
});
|
|
ws.addEventListener('error', (err) => {
|
|
console.error('[gstack terminal] reattach ws error', err);
|
|
});
|
|
}
|
|
|
|
async function checkClaudeAvailable(terminalPort) {
|
|
try {
|
|
const resp = await fetch(`http://127.0.0.1:${terminalPort}/claude-available`, {
|
|
credentials: 'include',
|
|
});
|
|
if (!resp.ok) return { available: false };
|
|
return await resp.json();
|
|
} catch {
|
|
return { available: false };
|
|
}
|
|
}
|
|
|
|
function ensureXterm() {
|
|
if (term) return;
|
|
term = new Terminal({
|
|
// Windows Latin monospace (Consolas, always present; Cascadia ships with Terminal/VS)
|
|
// MUST precede the CJK fallbacks: xterm sizes the character cell from the first
|
|
// resolved font's advance width, and Malgun Gothic (the Windows Korean UI font, the
|
|
// first that resolves here when the Mac/Linux fonts are absent) advances Latin glyphs
|
|
// in a full-width CJK cell — doubling every cell into spaced-out text. The CJK fonts
|
|
// stay last for actual CJK glyph coverage.
|
|
fontFamily: '"JetBrains Mono", "SF Mono", "Cascadia Mono", "Cascadia Code", Consolas, Menlo, "DejaVu Sans Mono", "Noto Sans Mono CJK KR", "Malgun Gothic", monospace',
|
|
fontSize: FONT_SIZES[fontSize],
|
|
theme: { background: '#0a0a0a', foreground: '#e5e5e5' },
|
|
cursorBlink: true,
|
|
scrollback: 5000,
|
|
allowTransparency: false,
|
|
convertEol: false,
|
|
});
|
|
if (FitAddonModule && FitAddonModule.FitAddon) {
|
|
fitAddon = new FitAddonModule.FitAddon();
|
|
term.loadAddon(fitAddon);
|
|
}
|
|
// CRITICAL: caller must make els.mount visible BEFORE invoking
|
|
// ensureXterm. xterm.js measures the container synchronously inside
|
|
// term.open() — if the mount is display:none, xterm caches a 0-size
|
|
// viewport and never auto-grows even after the container goes
|
|
// visible. The visible-first pattern is enforced by connect()
|
|
// calling setState(STATE.LIVE) before us.
|
|
term.open(els.mount);
|
|
// First fit waits for the next paint frame so the browser has
|
|
// applied the .active class transition. Otherwise term.cols/rows
|
|
// can come back as the minimum (2x2) when the mount's clientHeight
|
|
// is still being computed.
|
|
requestAnimationFrame(() => {
|
|
try {
|
|
fitAddon && fitAddon.fit();
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
}
|
|
} catch {}
|
|
});
|
|
|
|
const ro = new ResizeObserver(() => {
|
|
try {
|
|
fitAddon && fitAddon.fit();
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
}
|
|
} catch {}
|
|
});
|
|
ro.observe(els.mount);
|
|
|
|
// IME composition (Korean/CJK, issue #1272) is handled by xterm.js
|
|
// itself: partial jamo are suppressed while _isComposing, and the final
|
|
// composed string is emitted through onData once, asynchronously
|
|
// (setTimeout in _finalizeComposition). A previous local workaround
|
|
// sent e.data manually on compositionend — but xterm emits the same
|
|
// string one macrotask later, so every composed syllable went out
|
|
// TWICE. Do not re-add a manual compositionend send.
|
|
|
|
term.onData((data) => {
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(new TextEncoder().encode(data));
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Inject a string into the live PTY (the same way a real keystroke would).
|
|
* Used by the toolbar's Cleanup button and the Inspector's "Send to Code"
|
|
* action so the user can drive claude from outside-the-keyboard surfaces.
|
|
* Returns true if the bytes went out, false if no live session.
|
|
*
|
|
* IMPORTANT (D6): this function stays SYNCHRONOUS and SCAN-FREE. Page-
|
|
* derived input MUST be pre-scanned via window.gstackScanForPTYInject()
|
|
* before calling this. The invariant test in
|
|
* test/extension-pty-inject-invariant.test.ts fails the build if any
|
|
* extension/*.js path calls this without the preceding scan.
|
|
*
|
|
* Why not move the scan inside this function: callers already use the
|
|
* sync `const ok = gstackInjectToTerminal?.(text)` pattern. Making the
|
|
* inject async would turn `ok` into a Promise and silently break every
|
|
* existing call site. Pre-scanning at the caller keeps the boundary
|
|
* clean and the invariant testable.
|
|
*/
|
|
window.gstackInjectToTerminal = function (text) {
|
|
if (!text || !ws || ws.readyState !== WebSocket.OPEN) return false;
|
|
try {
|
|
ws.send(new TextEncoder().encode(text));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Scan page-derived text via the browse server's /pty-inject-scan
|
|
* endpoint before injecting it into the PTY. Returns:
|
|
* { allow: true, verdict: "PASS" } → safe to inject
|
|
* { allow: true, verdict: "WARN", reasons: [...] } → caller should
|
|
* prompt the user before injecting
|
|
* { allow: false, verdict: "BLOCK", reasons: [...]} → drop the text;
|
|
* caller should surface a banner to the user
|
|
*
|
|
* On any network / endpoint failure: returns
|
|
* { allow: true, verdict: "WARN", reasons: ["scan-unreachable"] }
|
|
* so the caller falls back to WARN+confirm rather than silent PASS.
|
|
*
|
|
* Closes #1370.
|
|
*/
|
|
window.gstackScanForPTYInject = async function (text, origin) {
|
|
if (!text) return { allow: false, verdict: 'BLOCK', reasons: ['empty-text'] };
|
|
try {
|
|
const serverPort = getServerPort() || 34567;
|
|
const resp = await fetch(`http://127.0.0.1:${serverPort}/pty-inject-scan`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${await getAuthTokenForScan()}`,
|
|
},
|
|
body: JSON.stringify({ text, origin: origin || 'extension' }),
|
|
});
|
|
if (!resp.ok) {
|
|
return { allow: true, verdict: 'WARN', reasons: [`scan-http-${resp.status}`] };
|
|
}
|
|
const body = await resp.json();
|
|
const verdict = body.verdict || 'WARN';
|
|
const allow = verdict !== 'BLOCK';
|
|
return { allow, verdict, reasons: body.reasons || [], l4: body.l4 };
|
|
} catch (err) {
|
|
return {
|
|
allow: true,
|
|
verdict: 'WARN',
|
|
reasons: ['scan-unreachable', err && err.message ? err.message : 'fetch-failed'],
|
|
};
|
|
}
|
|
};
|
|
|
|
// The auth token for /pty-inject-scan comes from the same source the
|
|
// sidepanel uses for /pty-session — window.gstackAuthToken, set by
|
|
// sidepanel.js after the pinned-origin POST /extension-token bootstrap.
|
|
// The old fallback here fetched /health and read token keys the server
|
|
// never sent (AUTH_TOKEN/authToken) — dead code since /health stopped
|
|
// carrying any token.
|
|
async function getAuthTokenForScan() {
|
|
return getAuthToken() || '';
|
|
}
|
|
|
|
async function connect() {
|
|
if (state !== STATE.IDLE) return; // already connecting/live
|
|
setState(STATE.CONNECTING);
|
|
|
|
const minted = await mintSession();
|
|
if (minted.error) {
|
|
// 401 = stale auth token; no amount of retrying will fix it. Sticky
|
|
// abort the polling loop so we don't spam the same error every 2s
|
|
// until the user clicks Restart (which clears the flag).
|
|
if (typeof minted.error === 'string' && minted.error.startsWith('401')) {
|
|
autoConnectAborted = true;
|
|
setState(STATE.IDLE, {
|
|
message: 'Auth invalid — reload the sidebar or restart your gstack session.',
|
|
});
|
|
return;
|
|
}
|
|
setState(STATE.IDLE, { message: `Cannot start: ${minted.error}` });
|
|
return;
|
|
}
|
|
// v1.44 4-tuple: { terminalPort, sessionId, attachToken, leaseExpiresAt }
|
|
// Falls back to the legacy `ptySessionToken` field for one minor release
|
|
// (server keeps the alias) so a partially-updated extension still works
|
|
// against a fresh server.
|
|
const { terminalPort, sessionId } = minted;
|
|
const attachToken = minted.attachToken || minted.ptySessionToken;
|
|
if (!attachToken) {
|
|
setState(STATE.IDLE, { message: 'Cannot start: no attach token returned' });
|
|
return;
|
|
}
|
|
currentSessionId = sessionId || null;
|
|
// Expose for sidepanel.js pagehide handler — see Commit 2C wiring.
|
|
try { window.gstackPtySession = currentSessionId; } catch {}
|
|
|
|
// Pre-flight: does claude even exist on PATH?
|
|
const claudeStatus = await checkClaudeAvailable(terminalPort);
|
|
if (!claudeStatus.available) {
|
|
setState(STATE.NO_CLAUDE);
|
|
return;
|
|
}
|
|
|
|
// setState(LIVE) flips terminal-mount from display:none to display:flex.
|
|
// We MUST do that BEFORE ensureXterm() — xterm.js measures the container
|
|
// synchronously inside term.open() and a hidden container yields a 0x0
|
|
// terminal that never recovers. ensureXterm + the requestAnimationFrame
|
|
// fit() inside it run after the browser has applied the layout.
|
|
setState(STATE.LIVE);
|
|
ensureXterm();
|
|
|
|
// Token rides on Sec-WebSocket-Protocol — the only auth header the
|
|
// browser WebSocket API lets us set. Cross-port HttpOnly cookies with
|
|
// SameSite=Strict don't survive the jump from server.ts:34567 to the
|
|
// agent's random port from a chrome-extension origin, so cookies
|
|
// alone weren't reliable.
|
|
ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [`gstack-pty.${attachToken}`]);
|
|
ws.binaryType = 'arraybuffer';
|
|
|
|
ws.addEventListener('open', () => {
|
|
try {
|
|
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
} catch {}
|
|
// Push a fresh tab snapshot so claude's tabs.json is populated by
|
|
// the time the lazy spawn finishes booting. Background.js exposes
|
|
// the snapshot helper via chrome.runtime; we ask for it here and
|
|
// forward whatever comes back.
|
|
try {
|
|
chrome.runtime.sendMessage({ type: 'getTabState' }, (resp) => {
|
|
if (resp && ws && ws.readyState === WebSocket.OPEN) {
|
|
try {
|
|
ws.send(JSON.stringify({
|
|
type: 'tabState',
|
|
active: resp.active,
|
|
tabs: resp.tabs,
|
|
reason: 'initial',
|
|
}));
|
|
} catch {}
|
|
}
|
|
});
|
|
} catch {}
|
|
// v1.44 eager spawn: send {type:"start"} so the agent boots claude
|
|
// without requiring the user to type a keystroke. Pre-v1.44 the
|
|
// lazy-binary-spawn pattern made forceRestart look stuck for ~2-3s
|
|
// until the user pressed any key.
|
|
try { ws.send(JSON.stringify({ type: 'start' })); } catch {}
|
|
// v1.44 client-side keepalive. Server pings every 25s; we ALSO send
|
|
// keepalive frames at the same cadence so a paused timer on either
|
|
// side still has the other to lean on. Both are silently dropped
|
|
// by the agent's message handler.
|
|
if (keepaliveInterval) clearInterval(keepaliveInterval);
|
|
keepaliveInterval = setInterval(() => {
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
try { ws.send(JSON.stringify({ type: 'keepalive' })); } catch {}
|
|
}, KEEPALIVE_INTERVAL_MS);
|
|
});
|
|
|
|
ws.addEventListener('message', (ev) => {
|
|
if (typeof ev.data === 'string') {
|
|
// Agent control message. Treat as JSON; error frames carry code,
|
|
// ping frames trigger an immediate pong reply.
|
|
try {
|
|
const msg = JSON.parse(ev.data);
|
|
if (msg.type === 'error' && msg.code === 'CLAUDE_NOT_FOUND') {
|
|
setState(STATE.NO_CLAUDE);
|
|
try { ws.close(); } catch {}
|
|
return;
|
|
}
|
|
if (msg.type === 'ping') {
|
|
// Mirror the server's timestamp back. Cheap liveness ACK that
|
|
// lets the agent observe round-trip latency for free.
|
|
try { ws.send(JSON.stringify({ type: 'pong', ts: msg.ts })); } catch {}
|
|
return;
|
|
}
|
|
} catch {}
|
|
return;
|
|
}
|
|
// Binary: feed to xterm.
|
|
const buf = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : ev.data;
|
|
term.write(buf);
|
|
});
|
|
|
|
ws.addEventListener('close', (ev) => {
|
|
ws = null;
|
|
if (keepaliveInterval) {
|
|
clearInterval(keepaliveInterval);
|
|
keepaliveInterval = null;
|
|
}
|
|
if (state === STATE.NO_CLAUDE) return;
|
|
// v1.44 Commit 3 — re-attach loop on transient close. Clean codes
|
|
// (1000 = pty exited, 4001 = intentional restart, 4404 = no-claude)
|
|
// skip the loop and fall through to ENDED. Any other code
|
|
// (1006 abnormal, 1001 going-away) is a candidate for re-attach
|
|
// within the 60s server-side detach window, provided we still
|
|
// have a sessionId to match against.
|
|
const code = (ev && (ev.code ?? 1006)) || 1006;
|
|
const intentional = code === 1000 || code === 4001 || code === 4404;
|
|
if (state === STATE.LIVE && !intentional && currentSessionId) {
|
|
startReattachLoop(currentSessionId);
|
|
return;
|
|
}
|
|
setState(STATE.ENDED);
|
|
});
|
|
|
|
ws.addEventListener('error', (err) => {
|
|
console.error('[gstack terminal] ws error', err);
|
|
});
|
|
}
|
|
|
|
function teardown() {
|
|
if (keepaliveInterval) {
|
|
clearInterval(keepaliveInterval);
|
|
keepaliveInterval = null;
|
|
}
|
|
try { ws && ws.close(); } catch {}
|
|
ws = null;
|
|
if (term) {
|
|
try { term.dispose(); } catch {}
|
|
term = null;
|
|
fitAddon = null;
|
|
}
|
|
setState(STATE.IDLE);
|
|
}
|
|
|
|
// ─── Wiring ───────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Force a fresh session: close any open WS, dispose xterm, return to
|
|
* IDLE, kick off auto-connect. Safe to call from any state.
|
|
*/
|
|
/**
|
|
* v1.44 forceRestart: hits the server's /pty-restart one-transaction
|
|
* endpoint with the current sessionId. The server kills the old PtySession
|
|
* scope-to-our-id, mints a fresh sessionId + lease + attachToken, and
|
|
* returns the new 4-tuple in one round trip. Zero race window between
|
|
* kill and mint (codex D8).
|
|
*
|
|
* If we don't have a sessionId (sidebar is in IDLE / ENDED state because
|
|
* the prior session ended cleanly), the route accepts that gracefully —
|
|
* skips the dispose step and just mints fresh. Either way the user sees
|
|
* the same "Restarting..." → fresh prompt UX.
|
|
*/
|
|
async function forceRestart() {
|
|
if (keepaliveInterval) {
|
|
clearInterval(keepaliveInterval);
|
|
keepaliveInterval = null;
|
|
}
|
|
// Re-arm the auto-connect loop in case a prior auth failure stuck the
|
|
// sticky flag — explicit user action is the cleared-flag signal.
|
|
autoConnectAborted = false;
|
|
setState(STATE.IDLE, { message: 'Restarting Claude Code...' });
|
|
|
|
const serverPort = getServerPort();
|
|
const authToken = getAuthToken();
|
|
const priorSessionId = currentSessionId;
|
|
|
|
// Close the local WS BEFORE the server-side kill so the agent's
|
|
// close handler doesn't race with the dispose call.
|
|
try { ws && ws.close(4001, 'intentional-restart'); } catch {}
|
|
ws = null;
|
|
if (term) {
|
|
try { term.dispose(); } catch {}
|
|
term = null;
|
|
fitAddon = null;
|
|
}
|
|
|
|
if (!serverPort || !authToken) {
|
|
// Server hasn't been discovered yet — fall back to the patient
|
|
// polling loop. forceRestart's promise of "fresh prompt now" can't
|
|
// be met without a live server; user sees the patient status path.
|
|
tryAutoConnect();
|
|
return;
|
|
}
|
|
|
|
let nextTuple = null;
|
|
try {
|
|
const resp = await fetch(`http://127.0.0.1:${serverPort}/pty-restart`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${authToken}`,
|
|
},
|
|
body: JSON.stringify(priorSessionId ? { sessionId: priorSessionId } : {}),
|
|
credentials: 'include',
|
|
});
|
|
if (resp.ok) {
|
|
nextTuple = await resp.json();
|
|
} else if (resp.status === 401) {
|
|
autoConnectAborted = true;
|
|
setState(STATE.IDLE, {
|
|
message: 'Auth invalid — reload the sidebar or restart your gstack session.',
|
|
});
|
|
return;
|
|
} else if (resp.status === 503) {
|
|
// Agent down — fall through to patient autoconnect which will
|
|
// surface the appropriate "waiting for server" status.
|
|
setState(STATE.IDLE, { message: 'Restart failed: terminal agent not ready. Retrying...' });
|
|
} else {
|
|
const body = await resp.text().catch(() => '');
|
|
setState(STATE.IDLE, { message: `Restart failed: ${resp.status} ${body || resp.statusText}` });
|
|
}
|
|
} catch (err) {
|
|
setState(STATE.IDLE, {
|
|
message: `Restart failed: ${err && err.message ? err.message : String(err)}`,
|
|
});
|
|
}
|
|
|
|
if (!nextTuple) {
|
|
// Restart didn't yield a fresh tuple. Fall back to the regular
|
|
// connect path; tryAutoConnect will retry as the server recovers.
|
|
currentSessionId = null;
|
|
try { window.gstackPtySession = null; } catch {}
|
|
tryAutoConnect();
|
|
return;
|
|
}
|
|
|
|
// We have a fresh 4-tuple — open the new WS directly without going
|
|
// through mintSession again. This is the explicit "no race window"
|
|
// path the codex D8 redesign was after.
|
|
const { terminalPort, sessionId, attachToken, expiresAt: _expiresAt } = nextTuple;
|
|
const token = attachToken || nextTuple.ptySessionToken;
|
|
if (!terminalPort || !token) {
|
|
currentSessionId = null;
|
|
tryAutoConnect();
|
|
return;
|
|
}
|
|
currentSessionId = sessionId || null;
|
|
try { window.gstackPtySession = currentSessionId; } catch {}
|
|
|
|
// Pre-flight: claude still on PATH?
|
|
const claudeStatus = await checkClaudeAvailable(terminalPort);
|
|
if (!claudeStatus.available) {
|
|
setState(STATE.NO_CLAUDE);
|
|
return;
|
|
}
|
|
|
|
setState(STATE.LIVE);
|
|
ensureXterm();
|
|
ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [`gstack-pty.${token}`]);
|
|
ws.binaryType = 'arraybuffer';
|
|
|
|
ws.addEventListener('open', () => {
|
|
try {
|
|
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
} catch {}
|
|
try {
|
|
chrome.runtime.sendMessage({ type: 'getTabState' }, (resp) => {
|
|
if (resp && ws && ws.readyState === WebSocket.OPEN) {
|
|
try {
|
|
ws.send(JSON.stringify({
|
|
type: 'tabState',
|
|
active: resp.active,
|
|
tabs: resp.tabs,
|
|
reason: 'restart',
|
|
}));
|
|
} catch {}
|
|
}
|
|
});
|
|
} catch {}
|
|
// Eager spawn — fresh claude prompt visible without user keystroke.
|
|
try { ws.send(JSON.stringify({ type: 'start' })); } catch {}
|
|
if (keepaliveInterval) clearInterval(keepaliveInterval);
|
|
keepaliveInterval = setInterval(() => {
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
try { ws.send(JSON.stringify({ type: 'keepalive' })); } catch {}
|
|
}, KEEPALIVE_INTERVAL_MS);
|
|
});
|
|
|
|
ws.addEventListener('message', (ev) => {
|
|
if (typeof ev.data === 'string') {
|
|
try {
|
|
const msg = JSON.parse(ev.data);
|
|
if (msg.type === 'error' && msg.code === 'CLAUDE_NOT_FOUND') {
|
|
setState(STATE.NO_CLAUDE);
|
|
try { ws.close(); } catch {}
|
|
return;
|
|
}
|
|
if (msg.type === 'ping') {
|
|
try { ws.send(JSON.stringify({ type: 'pong', ts: msg.ts })); } catch {}
|
|
return;
|
|
}
|
|
} catch {}
|
|
return;
|
|
}
|
|
const buf = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : ev.data;
|
|
term.write(buf);
|
|
});
|
|
|
|
ws.addEventListener('close', (ev) => {
|
|
ws = null;
|
|
if (keepaliveInterval) {
|
|
clearInterval(keepaliveInterval);
|
|
keepaliveInterval = null;
|
|
}
|
|
if (state === STATE.NO_CLAUDE) return;
|
|
// v1.44 Commit 3 — re-attach loop on transient close. Clean codes
|
|
// (1000 = pty exited, 4001 = intentional restart, 4404 = no-claude)
|
|
// skip the loop and fall through to ENDED. Any other code
|
|
// (1006 abnormal, 1001 going-away) is a candidate for re-attach
|
|
// within the 60s server-side detach window, provided we still
|
|
// have a sessionId to match against.
|
|
const code = (ev && (ev.code ?? 1006)) || 1006;
|
|
const intentional = code === 1000 || code === 4001 || code === 4404;
|
|
if (state === STATE.LIVE && !intentional && currentSessionId) {
|
|
startReattachLoop(currentSessionId);
|
|
return;
|
|
}
|
|
setState(STATE.ENDED);
|
|
});
|
|
ws.addEventListener('error', (err) => {
|
|
console.error('[gstack terminal] ws error', err);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Repaint xterm when the Terminal pane becomes visible. xterm.js has a
|
|
* known issue where its renderer doesn't redraw after a display:none →
|
|
* display:flex flip — the canvas/DOM stays blank until something forces
|
|
* a layout pass. fit() recomputes dimensions, refresh() redraws.
|
|
*/
|
|
function repaintIfLive() {
|
|
if (state !== STATE.LIVE || !term) return;
|
|
try { fitAddon && fitAddon.fit(); } catch {}
|
|
try { term.refresh(0, term.rows - 1); } catch {}
|
|
try {
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
function init() {
|
|
setState(STATE.IDLE, { message: 'Starting Claude Code...' });
|
|
|
|
els.installRetry?.addEventListener('click', () => {
|
|
// Re-probe claude on PATH, then try a connect.
|
|
setState(STATE.IDLE, { message: 'Starting Claude Code...' });
|
|
tryAutoConnect();
|
|
});
|
|
|
|
// Two restart buttons:
|
|
// - els.restart lives inside the ENDED state card (visible only after
|
|
// a session has ended).
|
|
// - els.restartNow lives in the always-visible toolbar (lets the user
|
|
// force a fresh claude mid-session without waiting for it to exit).
|
|
els.restart?.addEventListener('click', forceRestart);
|
|
els.restartNow?.addEventListener('click', forceRestart);
|
|
|
|
// xterm re-measures its character cell on the next render frame, so a
|
|
// live size change fits and tells the PTY its new cols/rows two frames
|
|
// later; fitting in the same tick reads the old cell size.
|
|
const applyFontSize = (size, persist) => {
|
|
fontSize = FONT_SIZES[size] ? size : 'sm';
|
|
if (els.fontSize) els.fontSize.value = fontSize;
|
|
if (persist) {
|
|
try { chrome.storage.local.set({ [FONT_SIZE_KEY]: fontSize }); } catch {}
|
|
}
|
|
if (!term) return;
|
|
term.options.fontSize = FONT_SIZES[fontSize];
|
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
try {
|
|
fitAddon && fitAddon.fit();
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
}
|
|
} catch {}
|
|
}));
|
|
};
|
|
els.fontSize?.addEventListener('change', (e) => applyFontSize(e.target.value, true));
|
|
try {
|
|
chrome.storage.local.get([FONT_SIZE_KEY], (r) => applyFontSize(r && r[FONT_SIZE_KEY], false));
|
|
} catch {}
|
|
|
|
// Live browser-tab state. background.js → sidepanel.js → us. We
|
|
// forward over the live PTY WebSocket; terminal-agent.ts writes
|
|
// <stateDir>/active-tab.json + <stateDir>/tabs.json so claude can
|
|
// always read the current tab landscape.
|
|
document.addEventListener('gstack:tab-state', (ev) => {
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
try {
|
|
ws.send(JSON.stringify({
|
|
type: 'tabState',
|
|
active: ev.detail?.active,
|
|
tabs: ev.detail?.tabs,
|
|
reason: ev.detail?.reason,
|
|
}));
|
|
} catch {}
|
|
});
|
|
|
|
// Repaint after a debug-tab → primary-pane transition. The debug
|
|
// tabs (Activity / Refs / Inspector) hide the Terminal pane via
|
|
// .tab-content { display: none }; xterm doesn't auto-redraw when its
|
|
// container flips back to visible, so we listen for the close-debug
|
|
// event and force a fit + refresh.
|
|
const observer = new MutationObserver(() => {
|
|
const term = document.getElementById('tab-terminal');
|
|
if (term?.classList.contains('active')) {
|
|
requestAnimationFrame(repaintIfLive);
|
|
}
|
|
});
|
|
const target = document.getElementById('tab-terminal');
|
|
if (target) observer.observe(target, { attributes: true, attributeFilter: ['class'] });
|
|
|
|
tryAutoConnect();
|
|
}
|
|
|
|
/**
|
|
* Eager-connect when the sidebar opens. Polls for sidepanel.js to populate
|
|
* window.gstackServerPort + window.gstackAuthToken (which it does as soon
|
|
* as /health succeeds), then fires connect() automatically. The user
|
|
* doesn't have to press a key — Terminal is the default tab and "tap to
|
|
* start" was a needless paper cut on every reload.
|
|
*
|
|
* v1.44 patience overhaul: no more 15s give-up. The user already opened
|
|
* the sidebar; giving up tells them "you did something wrong" when the
|
|
* truth is the daemon is slow to boot (or restarting via the upstream
|
|
* supervisor). We poll forever at 2s intervals with ascending status
|
|
* messages so the user knows we're still trying, and ONLY abort on
|
|
* explicit signals: state transition out of IDLE (connect succeeded
|
|
* or user navigated), or an unrecoverable auth/network signal.
|
|
*/
|
|
function tryAutoConnect() {
|
|
if (state !== STATE.IDLE) return;
|
|
if (autoConnectAborted) return;
|
|
const startedAt = Date.now();
|
|
const tick = () => {
|
|
// If the user navigated away (Chat tab) or already connected, drop out.
|
|
if (state !== STATE.IDLE) return;
|
|
// If a prior attempt hit an unrecoverable error (401, etc.), stop
|
|
// polling. The user clears the flag by clicking Restart.
|
|
if (autoConnectAborted) return;
|
|
if (getServerPort() && getAuthToken()) {
|
|
connect();
|
|
return;
|
|
}
|
|
// Ascending status messages — the user wants to know the sidebar is
|
|
// still trying. Each threshold is the moment the message would
|
|
// mislead if left silent: at 15s "should have started by now," at
|
|
// 60s "the server might be in trouble," at 5min "stop waiting and
|
|
// check on it manually."
|
|
const elapsed = Date.now() - startedAt;
|
|
if (elapsed > 300_000) {
|
|
setState(STATE.IDLE, {
|
|
message: 'Browse server still not responding after 5 min. Try `$B status` in a terminal.',
|
|
});
|
|
} else if (elapsed > 60_000) {
|
|
setState(STATE.IDLE, {
|
|
message: 'Still waiting — browse server may be slow to start.',
|
|
});
|
|
} else if (elapsed > 15_000) {
|
|
setState(STATE.IDLE, { message: 'Waiting for browse server...' });
|
|
}
|
|
setTimeout(tick, 2000);
|
|
};
|
|
tick();
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|