feat(web): fence dart:ffi behind web stubs so the WASM build compiles (T-438, D-100)

`flutter build web --wasm` had been broken since the tree-sitter/PTY dart:ffi
pivot. Per D-100 (resolving Q-50: keep the web "happy accident" alive), every
native binding now sits behind a `dart.library.ffi` conditional import with a
graceful web stub. Desktop builds are unchanged — no fidelity loss; the web
target degrades (no terminal, native git, or syntax highlighting).

Discriminator is `dart.library.ffi`, not `dart.library.io` — dart2wasm provides
dart:io, so FFI is the only blocker.

Fences:
- PTY: pty_session → pty_backend_io / pty_backend_web (stub throws).
- tree-sitter: pure types → syntax_result.dart; tree_sitter_service is now a
  facade over _ffi/_stub; tree_sitter_boot_io/stub fences TreeSitterLib.init().
- watchdog: watchdog_windows_stub (all -1 sampler).
- claude ABI probe: native_abi_io/stub (was `dart:ffi show Abi`).
- testmode fd-check: fd_check_io/stub.

Also dart2js-safe: the 64-bit FNV literals in session_naming.dart + paths.dart
(the dual JS fallback rejected them) — split into 32-bit halves, dropped a
no-op 64-bit mask. Desktop/wasm hash values unchanged.

CI: added a `web-wasm` job (flutter build web --wasm) so the fence can't rot.
Two FFI-constructing tree-sitter tests import _ffi.dart directly (the analyzer
resolves the conditional facade to the stub branch).

Verified: `flutter build web --wasm` → built; `flutter analyze` clean;
`make test` green. Full Playwright e2e harness wiring is the tracked follow-on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 17:31:24 +02:00
co-authored by Claude Opus 4.8
parent 967db2f8d9
commit ca08c2a17d
28 changed files with 796 additions and 380 deletions
+15 -7
View File
@@ -114,13 +114,21 @@ jobs:
path: ${{ github.workspace }}/clide-logs path: ${{ github.workspace }}/clide-logs
if-no-files-found: ignore if-no-files-found: ignore
# The web-WASM Playwright job is withheld: `flutter build web --wasm` cannot web-wasm:
# compile the tree since the tree-sitter/PTY dart:ffi pivot (dart:ffi is name: web build (wasm compile gate)
# unavailable on the wasm target). Whether the web target gets conditional- runs-on: ubuntu-latest
# import fences or is dropped is an open question — see Q-50 in steps:
# governance/questions/architecture.md. Re-add the job (setup-node, npm - uses: actions/checkout@v4
# install + playwright install in tools/ui, `make test-e2e`) when Q-50 - uses: subosito/flutter-action@v2
# resolves toward keeping it. with: { channel: stable, cache: true }
- run: flutter pub get
# Compile gate for the dart:ffi web fence (D-100 / T-438, resolving Q-50):
# every native binding lives behind a `dart.library.ffi` conditional import
# with a web stub. If a new one lands without its stub, this fails — the
# fence can't silently rot. Build-only for now; the full web-WASM Playwright
# e2e (`make test-e2e`: setup-node + playwright install in tools/ui +
# serve) is the follow-on once the harness is wired back up.
- run: flutter build web --wasm
docs: docs:
name: dart doc (lib API) name: dart doc (lib API)
+81
View File
@@ -5373,3 +5373,84 @@ The one live remnant — the dead `make test-e2e` / `ui-dev` / `ui-smoke` target
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCVPGCKEGDC54KKQ120SRM', 'status', 'review', 'done', NULL, '2026-06-15 14:25:10', '2026-06-15 14:25:10', '2026-06-15 14:25:10', NULL, 'fba93f08e50b81ab6c3c4b5d1dd4f1d9', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCVPGCKEGDC54KKQ120SRM', 'status', 'review', 'done', NULL, '2026-06-15 14:25:10', '2026-06-15 14:25:10', '2026-06-15 14:25:10', NULL, 'fba93f08e50b81ab6c3c4b5d1dd4f1d9', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'status', 'backlog', 'in_progress', NULL, '2026-06-15 14:32:22', '2026-06-15 14:32:22', '2026-06-15 14:32:22', NULL, '80f8ce4f94230fe7c842bd25e1b7f131', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'status', 'backlog', 'in_progress', NULL, '2026-06-15 14:32:22', '2026-06-15 14:32:22', '2026-06-15 14:32:22', NULL, '80f8ce4f94230fe7c842bd25e1b7f131', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'status', 'in_progress', 'in_progress', NULL, '2026-06-15 14:33:11', '2026-06-15 14:33:11', '2026-06-15 14:33:11', NULL, '783dee14da908512f766c8d9b0351e1d', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'status', 'in_progress', 'in_progress', NULL, '2026-06-15 14:33:11', '2026-06-15 14:33:11', '2026-06-15 14:33:11', NULL, '783dee14da908512f766c8d9b0351e1d', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'description', NULL, '**Symptom (user, recurring).** When clide is launched from a desktop/dock launcher (not a terminal), it doesn''t see the normal interactive-shell `PATH`, so spawned tools and installed components go missing — pql, git, claude-invoked CLIs, etc. Launching from a terminal works (the shell PATH is inherited).
**Root cause.** A GUI/desktop-launched process inherits a minimal `PATH` (roughly `/usr/bin:/bin`) — it never sources `~/.bashrc` / `~/.zprofile` / `/etc/profile.d` / brew shellenv, so `~/.local/bin`, `/opt/homebrew/bin`, and any user-customized dirs (nvm, pyenv, cargo, …) are absent. clide builds the environment for everything it spawns from `Platform.environment`, so that impoverished PATH propagates everywhere.
**Why it keeps happening — divergent, partial PATH expansion.** There are THREE separate PATH-augmentation implementations, fixed inconsistently:
1. `lib/src/pty/env.dart` → `expandedPath` — **still macOS-only** (`if (!Platform.isMacOS) return base;`). Used by `lib/src/git/operations.dart:24` (git resolution) → on Linux desktop-launch, git gets the raw PATH.
2. `lib/kernel/src/toolchain_paths.dart` → `_expandedPath()` — augments on Linux too (this is what **T-347** fixed, for pql).
3. `lib/kernel/src/cli_install.dart` → `expandedPath(base, {macOS, home})` — a third copy.
So T-347 fixed the *toolchain/pql* path on Linux, but the `env.dart` copy (git, PTY env defaults) is still macOS-only, and claude''s spawn (`agent_bootstrap.agentEnvDelta` → `Process.start(environment:)` merged over `Platform.environment`) only prepends the clide-CLI dir — the rest of PATH stays un-enriched. Net: the same class of breakage recurs per spawn site because there''s no single source of truth.
**Also:** all three use a **hardcoded dir list** (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`). That misses arbitrary user customizations (nvm/pyenv/cargo/asdf/custom dirs) — the user''s "normal bash PATH" is whatever their login shell actually produces, not a fixed list.
**Proposed fix (two parts).**
1. *Robust resolution:* derive the real login-shell PATH once at startup — spawn the user''s `$SHELL -l -i -c ''printf %s "$PATH"''` (or `-l -c` to avoid interactive side-effects), with a short timeout and a graceful fallback to the current hardcoded-merge behavior. Cache it for the process. This is the established approach (VS Code''s `resolveShellEnv`, the `fix-path` pattern) and captures the user''s actual PATH, not a guess.
2. *Consolidation:* collapse the three `expandedPath`/`_expandedPath` copies into ONE source of truth (e.g. in `lib/src/pty/env.dart` or a small `kernel` env service) that every spawn site uses — PTY/terminal (`registry.dart:55` currently passes raw `Platform.environment`), git (`operations.dart`), toolchain (`toolchain_paths.dart`), claude (`agent_bootstrap.dart`), and any other `Process.start`. One resolver, applied everywhere.
**Acceptance.** Desktop-launched clide on Linux + macOS resolves the same PATH the user''s login shell has; pql/git/claude and PTY children all find user-installed tools; the three divergent expanders are unified into one; graceful fallback when the shell probe fails or times out; covered by a test for the resolver + the fallback.
**Related:** T-347 (done — fixed the Linux toolchain/pql path, but only `toolchain_paths.dart`), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE + clide on the child PATH), T-211/T-212 (clide-on-PATH install), D-59 (bundled git) / D-92 (bundled pql) — bundling covers git/pql specifically, but not the general "user''s installed tools" PATH this addresses.', NULL, '2026-06-15 15:23:47', '2026-06-15 15:23:47', '2026-06-15 15:23:47', NULL, 'c5a27e255cd62d26167231269985ba39', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'description', 'Implements the D-100 fence decision (resolving Q-50): keep the web/WASM "happy accident" build compiling so the e2e/Playwright UI harness (D-26) can run again.
**Problem.** `flutter build web --wasm` cannot compile the tree — 12 modules import `dart:ffi` unconditionally, which the wasm target forbids. This kills `make test-e2e` / `ui-dev` / `ui-smoke` and the GitHub Actions web-WASM e2e job (currently withheld with a pointer to Q-50).
**dart:ffi importers to fence (as of 2026-06-15):**
- Native PTY: `lib/src/pty/ffi/libc.dart`, `lib/src/pty/native_pty.dart`, `lib/src/pty/windows_pty.dart`
- Tree-sitter: `lib/kernel/src/syntax/tree_sitter_ffi.dart`, `lib/kernel/src/syntax/tree_sitter_service.dart`
- Lua (Tier 6): `lib/lua/lua.dart`, `lib/lua/src/host.dart`
- Windows watchdog: `lib/kernel/src/watchdog_windows.dart`
- Consumers / barrels: `lib/clide.dart`, `lib/src/panes/pane.dart`, `lib/builtin/claude/src/agent_bootstrap.dart`, `lib/test_app.dart`
**Approach (per D-100).** Put each native capability behind a conditional-import facade — `import ''x_native.dart'' if (dart.library.ffi) ''x_native.dart'' ... else ''x_stub.dart''` (io/ffi → real impl; web → stub). Web stubs degrade gracefully and never throw at import time: no PTY/terminal, no native git, no tree-sitter highlighting on web — the web build is a UI/e2e surface, not a functional desktop replacement. Desktop builds keep the real FFI impls unchanged (no fidelity loss — the CLAUDE.md guardrail holds).
**Acceptance.**
- `flutter build web --wasm` compiles the tree.
- `make test-e2e` / `ui-dev` / `ui-smoke` run again.
- A `flutter build web --wasm` compile gate is added to CI so the fence can''t silently rot, and the withheld web-WASM e2e job in `.github/workflows/test.yml` is re-enabled.
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).', 'Implements the D-100 fence decision (resolving Q-50): keep the web/WASM "happy accident" build compiling so the e2e/Playwright UI harness (D-26) can run again.
**Problem.** `flutter build web --wasm` cannot compile the tree — 12 modules import `dart:ffi` unconditionally, which the wasm target forbids. This kills `make test-e2e` / `ui-dev` / `ui-smoke` and the GitHub Actions web-WASM e2e job (currently withheld with a pointer to Q-50).
**dart:ffi importers to fence (as of 2026-06-15):**
- Native PTY: `lib/src/pty/ffi/libc.dart`, `lib/src/pty/native_pty.dart`, `lib/src/pty/windows_pty.dart`
- Tree-sitter: `lib/kernel/src/syntax/tree_sitter_ffi.dart`, `lib/kernel/src/syntax/tree_sitter_service.dart`
- Lua (Tier 6): `lib/lua/lua.dart`, `lib/lua/src/host.dart`
- Windows watchdog: `lib/kernel/src/watchdog_windows.dart`
- Consumers / barrels: `lib/clide.dart`, `lib/src/panes/pane.dart`, `lib/builtin/claude/src/agent_bootstrap.dart`, `lib/test_app.dart`
**Approach (per D-100).** Put each native capability behind a conditional-import facade — `import ''x_native.dart'' if (dart.library.ffi) ''x_native.dart'' ... else ''x_stub.dart''` (io/ffi → real impl; web → stub). Web stubs degrade gracefully and never throw at import time: no PTY/terminal, no native git, no tree-sitter highlighting on web — the web build is a UI/e2e surface, not a functional desktop replacement. Desktop builds keep the real FFI impls unchanged (no fidelity loss — the CLAUDE.md guardrail holds).
**Acceptance.**
- `flutter build web --wasm` compiles the tree.
- `make test-e2e` / `ui-dev` / `ui-smoke` run again.
- A `flutter build web --wasm` compile gate is added to CI so the fence can''t silently rot, and the withheld web-WASM e2e job in `.github/workflows/test.yml` is re-enabled.
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).
---
**Implemented 2026-06-15 — dart:ffi fence done, web/WASM build compiles.**
`flutter build web --wasm` → `✓ Built build/web`. Desktop `flutter analyze` clean; `make test` (analyze + format + unit + widget + golden) green; UUID/socket-hash determinism unchanged.
**What was done.** Every `dart:ffi` importer now sits behind a `dart.library.ffi` conditional import with a graceful web stub (discriminator is `dart.library.ffi`, NOT `dart.library.io` — dart2wasm provides `dart:io`, so only FFI is the blocker):
- PTY: `pty_session.dart` → `pty_backend_io.dart` / `pty_backend_web.dart` (stub throws).
- tree-sitter: pure types extracted to `syntax_result.dart`; `tree_sitter_service.dart` is now a facade over `_ffi`/`_stub`; `tree_sitter_boot_io/stub.dart` fences `TreeSitterLib.init()` in main.dart. Web = no highlighting (plain text).
- watchdog: `watchdog_windows_stub.dart` (all-`-1` sampler).
- claude ABI probe: `native_abi_io/stub.dart` (replaces `dart:ffi show Abi`).
- testmode fd-check: `fd_check_io/stub.dart`.
- Two FFI-constructing tree-sitter tests now import `_ffi.dart` directly (the analyzer resolves the conditional facade to the stub branch).
**Second, distinct web-incompat fixed:** the dart2js fallback (built alongside wasm) rejected the 64-bit FNV literals (`0xcbf29ce484222325`) in `session_naming.dart` + `paths.dart` — split into 32-bit halves; dropped a no-op `& 0xFFFFFFFFFFFFFFFF` mask. Desktop/wasm values unchanged.
**CI:** added a `web-wasm` job (`flutter build web --wasm`) to `.github/workflows/test.yml` so the fence can''t silently rot (replaces the withheld-job comment).
**Remaining (why this is `review`, not `done`):** the *compile* gate is in CI, but the full web-WASM **Playwright e2e** (`make test-e2e` / `ui-smoke`: setup-node + `playwright install` in `tools/ui` + serve) is not yet wired into CI — it needs browser/node provisioning on the runner, separate from the fence. `make ui-dev` (build + serve) works now; the Playwright smoke is the follow-on. Acceptance items 1, 3a (compile gate), 4 met; 2/3b (full Playwright e2e) pending that harness wiring.', NULL, '2026-06-15 15:30:48', '2026-06-15 15:30:48', '2026-06-15 15:30:48', NULL, '0cf096a810a7a075307c8df8321b5c33', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'status', 'in_progress', 'review', NULL, '2026-06-15 15:30:48', '2026-06-15 15:30:48', '2026-06-15 15:30:48', NULL, 'a7d81444607b6ec3aa8440fcbc9abee3', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'status', 'backlog', 'in_progress', NULL, '2026-06-15 15:31:15', '2026-06-15 15:31:15', '2026-06-15 15:31:15', NULL, '5fed82be61479c8191ed8d0663507cad', 2) ON CONFLICT(hash) DO NOTHING;
+1
View File
@@ -263,3 +263,4 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9GAQ2G0KCVMZS67SK3324', 'T-436', '2026-06-15 07:19:11', '2026-06-15 07:19:11', NULL, '3114ab57de9b03aa1e745af01001eee1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9GAQ2G0KCVMZS67SK3324', 'T-436', '2026-06-15 07:19:11', '2026-06-15 07:19:11', NULL, '3114ab57de9b03aa1e745af01001eee1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'T-437', '2026-06-15 11:12:36', '2026-06-15 11:12:36', NULL, '74dd08c8c42f556959746ee1a47561e6', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'T-437', '2026-06-15 11:12:36', '2026-06-15 11:12:36', NULL, '74dd08c8c42f556959746ee1a47561e6', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'T-438', '2026-06-15 14:14:23', '2026-06-15 14:14:23', NULL, 'fa072ae8dd819784440bb44b5fe689d8', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash); INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'T-438', '2026-06-15 14:14:23', '2026-06-15 14:14:23', NULL, 'fa072ae8dd819784440bb44b5fe689d8', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'T-439', '2026-06-15 14:55:15', '2026-06-15 14:55:15', NULL, '2427484ebb324d731cb8e099a2ad04ab', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
+2
View File
@@ -3,3 +3,5 @@ INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, dele
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'regression', '2026-06-15 11:12:41', '2026-06-15 11:12:41', NULL, 'd1aee45df369a9afed1ecc7bc6043255', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash); INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'regression', '2026-06-15 11:12:41', '2026-06-15 11:12:41', NULL, 'd1aee45df369a9afed1ecc7bc6043255', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'claude-cli', '2026-06-15 11:12:41', '2026-06-15 11:12:41', NULL, '2d486f878edbd7a898ece18900e0d9d6', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash); INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'claude-cli', '2026-06-15 11:12:41', '2026-06-15 11:12:41', NULL, '2d486f878edbd7a898ece18900e0d9d6', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'web', '2026-06-15 14:22:06', '2026-06-15 14:22:06', NULL, '6867c9766259a7f09e81ff515973914e', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash); INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'web', '2026-06-15 14:22:06', '2026-06-15 14:22:06', NULL, '6867c9766259a7f09e81ff515973914e', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'path', '2026-06-15 15:24:11', '2026-06-15 15:24:11', NULL, 'e73b3b85732a2c21be23353a0f7f1493', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'web', '2026-06-15 15:24:11', '2026-06-15 15:24:11', NULL, '8c93337891e74d67d2465ba2c3b901d5', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
+121
View File
@@ -6113,3 +6113,124 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change. - Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).', 'in_progress', 'medium', NULL, NULL, 'D-100', '2026-06-15 14:14:23', '2026-06-15 14:33:11', NULL, '6733711cd4992646502b3d765eb107be', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); **Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).', 'in_progress', 'medium', NULL, NULL, 'D-100', '2026-06-15 14:14:23', '2026-06-15 14:33:11', NULL, '6733711cd4992646502b3d765eb107be', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'bug', NULL, 'Consolidate PATH resolution: one login-shell-derived PATH for every spawned tool (git/pql/claude/PTY)', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-15 14:55:15', '2026-06-15 14:55:15', NULL, '4291af0f25de8a8cc5f58e088b7072ce', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'bug', NULL, 'Consolidate PATH resolution: one login-shell-derived PATH for every spawned tool (git/pql/claude/PTY)', '**Symptom (user, recurring).** When clide is launched from a desktop/dock launcher (not a terminal), it doesn''t see the normal interactive-shell `PATH`, so spawned tools and installed components go missing — pql, git, claude-invoked CLIs, etc. Launching from a terminal works (the shell PATH is inherited).
**Root cause.** A GUI/desktop-launched process inherits a minimal `PATH` (roughly `/usr/bin:/bin`) — it never sources `~/.bashrc` / `~/.zprofile` / `/etc/profile.d` / brew shellenv, so `~/.local/bin`, `/opt/homebrew/bin`, and any user-customized dirs (nvm, pyenv, cargo, …) are absent. clide builds the environment for everything it spawns from `Platform.environment`, so that impoverished PATH propagates everywhere.
**Why it keeps happening — divergent, partial PATH expansion.** There are THREE separate PATH-augmentation implementations, fixed inconsistently:
1. `lib/src/pty/env.dart` → `expandedPath` — **still macOS-only** (`if (!Platform.isMacOS) return base;`). Used by `lib/src/git/operations.dart:24` (git resolution) → on Linux desktop-launch, git gets the raw PATH.
2. `lib/kernel/src/toolchain_paths.dart` → `_expandedPath()` — augments on Linux too (this is what **T-347** fixed, for pql).
3. `lib/kernel/src/cli_install.dart` → `expandedPath(base, {macOS, home})` — a third copy.
So T-347 fixed the *toolchain/pql* path on Linux, but the `env.dart` copy (git, PTY env defaults) is still macOS-only, and claude''s spawn (`agent_bootstrap.agentEnvDelta` → `Process.start(environment:)` merged over `Platform.environment`) only prepends the clide-CLI dir — the rest of PATH stays un-enriched. Net: the same class of breakage recurs per spawn site because there''s no single source of truth.
**Also:** all three use a **hardcoded dir list** (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`). That misses arbitrary user customizations (nvm/pyenv/cargo/asdf/custom dirs) — the user''s "normal bash PATH" is whatever their login shell actually produces, not a fixed list.
**Proposed fix (two parts).**
1. *Robust resolution:* derive the real login-shell PATH once at startup — spawn the user''s `$SHELL -l -i -c ''printf %s "$PATH"''` (or `-l -c` to avoid interactive side-effects), with a short timeout and a graceful fallback to the current hardcoded-merge behavior. Cache it for the process. This is the established approach (VS Code''s `resolveShellEnv`, the `fix-path` pattern) and captures the user''s actual PATH, not a guess.
2. *Consolidation:* collapse the three `expandedPath`/`_expandedPath` copies into ONE source of truth (e.g. in `lib/src/pty/env.dart` or a small `kernel` env service) that every spawn site uses — PTY/terminal (`registry.dart:55` currently passes raw `Platform.environment`), git (`operations.dart`), toolchain (`toolchain_paths.dart`), claude (`agent_bootstrap.dart`), and any other `Process.start`. One resolver, applied everywhere.
**Acceptance.** Desktop-launched clide on Linux + macOS resolves the same PATH the user''s login shell has; pql/git/claude and PTY children all find user-installed tools; the three divergent expanders are unified into one; graceful fallback when the shell probe fails or times out; covered by a test for the resolver + the fallback.
**Related:** T-347 (done — fixed the Linux toolchain/pql path, but only `toolchain_paths.dart`), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE + clide on the child PATH), T-211/T-212 (clide-on-PATH install), D-59 (bundled git) / D-92 (bundled pql) — bundling covers git/pql specifically, but not the general "user''s installed tools" PATH this addresses.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-15 14:55:15', '2026-06-15 15:23:47', NULL, '611a14a812f99edc4b615df9d2e8cdc4', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'task', NULL, 'Implement D-100: fence dart:ffi behind web stubs so flutter build web --wasm compiles; restore e2e/ui targets', 'Implements the D-100 fence decision (resolving Q-50): keep the web/WASM "happy accident" build compiling so the e2e/Playwright UI harness (D-26) can run again.
**Problem.** `flutter build web --wasm` cannot compile the tree — 12 modules import `dart:ffi` unconditionally, which the wasm target forbids. This kills `make test-e2e` / `ui-dev` / `ui-smoke` and the GitHub Actions web-WASM e2e job (currently withheld with a pointer to Q-50).
**dart:ffi importers to fence (as of 2026-06-15):**
- Native PTY: `lib/src/pty/ffi/libc.dart`, `lib/src/pty/native_pty.dart`, `lib/src/pty/windows_pty.dart`
- Tree-sitter: `lib/kernel/src/syntax/tree_sitter_ffi.dart`, `lib/kernel/src/syntax/tree_sitter_service.dart`
- Lua (Tier 6): `lib/lua/lua.dart`, `lib/lua/src/host.dart`
- Windows watchdog: `lib/kernel/src/watchdog_windows.dart`
- Consumers / barrels: `lib/clide.dart`, `lib/src/panes/pane.dart`, `lib/builtin/claude/src/agent_bootstrap.dart`, `lib/test_app.dart`
**Approach (per D-100).** Put each native capability behind a conditional-import facade — `import ''x_native.dart'' if (dart.library.ffi) ''x_native.dart'' ... else ''x_stub.dart''` (io/ffi → real impl; web → stub). Web stubs degrade gracefully and never throw at import time: no PTY/terminal, no native git, no tree-sitter highlighting on web — the web build is a UI/e2e surface, not a functional desktop replacement. Desktop builds keep the real FFI impls unchanged (no fidelity loss — the CLAUDE.md guardrail holds).
**Acceptance.**
- `flutter build web --wasm` compiles the tree.
- `make test-e2e` / `ui-dev` / `ui-smoke` run again.
- A `flutter build web --wasm` compile gate is added to CI so the fence can''t silently rot, and the withheld web-WASM e2e job in `.github/workflows/test.yml` is re-enabled.
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).
---
**Implemented 2026-06-15 — dart:ffi fence done, web/WASM build compiles.**
`flutter build web --wasm` → `✓ Built build/web`. Desktop `flutter analyze` clean; `make test` (analyze + format + unit + widget + golden) green; UUID/socket-hash determinism unchanged.
**What was done.** Every `dart:ffi` importer now sits behind a `dart.library.ffi` conditional import with a graceful web stub (discriminator is `dart.library.ffi`, NOT `dart.library.io` — dart2wasm provides `dart:io`, so only FFI is the blocker):
- PTY: `pty_session.dart` → `pty_backend_io.dart` / `pty_backend_web.dart` (stub throws).
- tree-sitter: pure types extracted to `syntax_result.dart`; `tree_sitter_service.dart` is now a facade over `_ffi`/`_stub`; `tree_sitter_boot_io/stub.dart` fences `TreeSitterLib.init()` in main.dart. Web = no highlighting (plain text).
- watchdog: `watchdog_windows_stub.dart` (all-`-1` sampler).
- claude ABI probe: `native_abi_io/stub.dart` (replaces `dart:ffi show Abi`).
- testmode fd-check: `fd_check_io/stub.dart`.
- Two FFI-constructing tree-sitter tests now import `_ffi.dart` directly (the analyzer resolves the conditional facade to the stub branch).
**Second, distinct web-incompat fixed:** the dart2js fallback (built alongside wasm) rejected the 64-bit FNV literals (`0xcbf29ce484222325`) in `session_naming.dart` + `paths.dart` — split into 32-bit halves; dropped a no-op `& 0xFFFFFFFFFFFFFFFF` mask. Desktop/wasm values unchanged.
**CI:** added a `web-wasm` job (`flutter build web --wasm`) to `.github/workflows/test.yml` so the fence can''t silently rot (replaces the withheld-job comment).
**Remaining (why this is `review`, not `done`):** the *compile* gate is in CI, but the full web-WASM **Playwright e2e** (`make test-e2e` / `ui-smoke`: setup-node + `playwright install` in `tools/ui` + serve) is not yet wired into CI — it needs browser/node provisioning on the runner, separate from the fence. `make ui-dev` (build + serve) works now; the Playwright smoke is the follow-on. Acceptance items 1, 3a (compile gate), 4 met; 2/3b (full Playwright e2e) pending that harness wiring.', 'in_progress', 'medium', NULL, NULL, 'D-100', '2026-06-15 14:14:23', '2026-06-15 15:30:48', NULL, '31d185462126f7a8782eec557356e68a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'task', NULL, 'Implement D-100: fence dart:ffi behind web stubs so flutter build web --wasm compiles; restore e2e/ui targets', 'Implements the D-100 fence decision (resolving Q-50): keep the web/WASM "happy accident" build compiling so the e2e/Playwright UI harness (D-26) can run again.
**Problem.** `flutter build web --wasm` cannot compile the tree — 12 modules import `dart:ffi` unconditionally, which the wasm target forbids. This kills `make test-e2e` / `ui-dev` / `ui-smoke` and the GitHub Actions web-WASM e2e job (currently withheld with a pointer to Q-50).
**dart:ffi importers to fence (as of 2026-06-15):**
- Native PTY: `lib/src/pty/ffi/libc.dart`, `lib/src/pty/native_pty.dart`, `lib/src/pty/windows_pty.dart`
- Tree-sitter: `lib/kernel/src/syntax/tree_sitter_ffi.dart`, `lib/kernel/src/syntax/tree_sitter_service.dart`
- Lua (Tier 6): `lib/lua/lua.dart`, `lib/lua/src/host.dart`
- Windows watchdog: `lib/kernel/src/watchdog_windows.dart`
- Consumers / barrels: `lib/clide.dart`, `lib/src/panes/pane.dart`, `lib/builtin/claude/src/agent_bootstrap.dart`, `lib/test_app.dart`
**Approach (per D-100).** Put each native capability behind a conditional-import facade — `import ''x_native.dart'' if (dart.library.ffi) ''x_native.dart'' ... else ''x_stub.dart''` (io/ffi → real impl; web → stub). Web stubs degrade gracefully and never throw at import time: no PTY/terminal, no native git, no tree-sitter highlighting on web — the web build is a UI/e2e surface, not a functional desktop replacement. Desktop builds keep the real FFI impls unchanged (no fidelity loss — the CLAUDE.md guardrail holds).
**Acceptance.**
- `flutter build web --wasm` compiles the tree.
- `make test-e2e` / `ui-dev` / `ui-smoke` run again.
- A `flutter build web --wasm` compile gate is added to CI so the fence can''t silently rot, and the withheld web-WASM e2e job in `.github/workflows/test.yml` is re-enabled.
- Desktop unit/widget/golden/integration suites stay green; no desktop behavior change.
**Refs:** D-100, Q-50, D-32 (the withheld e2e job), D-26 (Playwright driver), T-384 (the dead-targets bug this finishes).
---
**Implemented 2026-06-15 — dart:ffi fence done, web/WASM build compiles.**
`flutter build web --wasm` → `✓ Built build/web`. Desktop `flutter analyze` clean; `make test` (analyze + format + unit + widget + golden) green; UUID/socket-hash determinism unchanged.
**What was done.** Every `dart:ffi` importer now sits behind a `dart.library.ffi` conditional import with a graceful web stub (discriminator is `dart.library.ffi`, NOT `dart.library.io` — dart2wasm provides `dart:io`, so only FFI is the blocker):
- PTY: `pty_session.dart` → `pty_backend_io.dart` / `pty_backend_web.dart` (stub throws).
- tree-sitter: pure types extracted to `syntax_result.dart`; `tree_sitter_service.dart` is now a facade over `_ffi`/`_stub`; `tree_sitter_boot_io/stub.dart` fences `TreeSitterLib.init()` in main.dart. Web = no highlighting (plain text).
- watchdog: `watchdog_windows_stub.dart` (all-`-1` sampler).
- claude ABI probe: `native_abi_io/stub.dart` (replaces `dart:ffi show Abi`).
- testmode fd-check: `fd_check_io/stub.dart`.
- Two FFI-constructing tree-sitter tests now import `_ffi.dart` directly (the analyzer resolves the conditional facade to the stub branch).
**Second, distinct web-incompat fixed:** the dart2js fallback (built alongside wasm) rejected the 64-bit FNV literals (`0xcbf29ce484222325`) in `session_naming.dart` + `paths.dart` — split into 32-bit halves; dropped a no-op `& 0xFFFFFFFFFFFFFFFF` mask. Desktop/wasm values unchanged.
**CI:** added a `web-wasm` job (`flutter build web --wasm`) to `.github/workflows/test.yml` so the fence can''t silently rot (replaces the withheld-job comment).
**Remaining (why this is `review`, not `done`):** the *compile* gate is in CI, but the full web-WASM **Playwright e2e** (`make test-e2e` / `ui-smoke`: setup-node + `playwright install` in `tools/ui` + serve) is not yet wired into CI — it needs browser/node provisioning on the runner, separate from the fence. `make ui-dev` (build + serve) works now; the Playwright smoke is the follow-on. Acceptance items 1, 3a (compile gate), 4 met; 2/3b (full Playwright e2e) pending that harness wiring.', 'review', 'medium', NULL, NULL, 'D-100', '2026-06-15 14:14:23', '2026-06-15 15:30:48', NULL, '2820923f9f455f0ab74e76f79661c726', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'bug', NULL, 'Consolidate PATH resolution: one login-shell-derived PATH for every spawned tool (git/pql/claude/PTY)', '**Symptom (user, recurring).** When clide is launched from a desktop/dock launcher (not a terminal), it doesn''t see the normal interactive-shell `PATH`, so spawned tools and installed components go missing — pql, git, claude-invoked CLIs, etc. Launching from a terminal works (the shell PATH is inherited).
**Root cause.** A GUI/desktop-launched process inherits a minimal `PATH` (roughly `/usr/bin:/bin`) — it never sources `~/.bashrc` / `~/.zprofile` / `/etc/profile.d` / brew shellenv, so `~/.local/bin`, `/opt/homebrew/bin`, and any user-customized dirs (nvm, pyenv, cargo, …) are absent. clide builds the environment for everything it spawns from `Platform.environment`, so that impoverished PATH propagates everywhere.
**Why it keeps happening — divergent, partial PATH expansion.** There are THREE separate PATH-augmentation implementations, fixed inconsistently:
1. `lib/src/pty/env.dart` → `expandedPath` — **still macOS-only** (`if (!Platform.isMacOS) return base;`). Used by `lib/src/git/operations.dart:24` (git resolution) → on Linux desktop-launch, git gets the raw PATH.
2. `lib/kernel/src/toolchain_paths.dart` → `_expandedPath()` — augments on Linux too (this is what **T-347** fixed, for pql).
3. `lib/kernel/src/cli_install.dart` → `expandedPath(base, {macOS, home})` — a third copy.
So T-347 fixed the *toolchain/pql* path on Linux, but the `env.dart` copy (git, PTY env defaults) is still macOS-only, and claude''s spawn (`agent_bootstrap.agentEnvDelta` → `Process.start(environment:)` merged over `Platform.environment`) only prepends the clide-CLI dir — the rest of PATH stays un-enriched. Net: the same class of breakage recurs per spawn site because there''s no single source of truth.
**Also:** all three use a **hardcoded dir list** (`~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin`). That misses arbitrary user customizations (nvm/pyenv/cargo/asdf/custom dirs) — the user''s "normal bash PATH" is whatever their login shell actually produces, not a fixed list.
**Proposed fix (two parts).**
1. *Robust resolution:* derive the real login-shell PATH once at startup — spawn the user''s `$SHELL -l -i -c ''printf %s "$PATH"''` (or `-l -c` to avoid interactive side-effects), with a short timeout and a graceful fallback to the current hardcoded-merge behavior. Cache it for the process. This is the established approach (VS Code''s `resolveShellEnv`, the `fix-path` pattern) and captures the user''s actual PATH, not a guess.
2. *Consolidation:* collapse the three `expandedPath`/`_expandedPath` copies into ONE source of truth (e.g. in `lib/src/pty/env.dart` or a small `kernel` env service) that every spawn site uses — PTY/terminal (`registry.dart:55` currently passes raw `Platform.environment`), git (`operations.dart`), toolchain (`toolchain_paths.dart`), claude (`agent_bootstrap.dart`), and any other `Process.start`. One resolver, applied everywhere.
**Acceptance.** Desktop-launched clide on Linux + macOS resolves the same PATH the user''s login shell has; pql/git/claude and PTY children all find user-installed tools; the three divergent expanders are unified into one; graceful fallback when the shell probe fails or times out; covered by a test for the resolver + the fallback.
**Related:** T-347 (done — fixed the Linux toolchain/pql path, but only `toolchain_paths.dart`), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE + clide on the child PATH), T-211/T-212 (clide-on-PATH install), D-59 (bundled git) / D-92 (bundled pql) — bundling covers git/pql specifically, but not the general "user''s installed tools" PATH this addresses.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-15 14:55:15', '2026-06-15 15:31:15', NULL, 'c816196f97e6e80bd828c319f99619c8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+9
View File
@@ -49,6 +49,15 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
teardown now awaits the process's real death (SIGTERM, escalating to SIGKILL) teardown now awaits the process's real death (SIGTERM, escalating to SIGKILL)
before clearing the transcript and respawning. A dead pane also now shows the before clearing the transcript and respawning. A dead pane also now shows the
CLI's own reason instead of a bare "exited (code 1)". (T-437) CLI's own reason instead of a bare "exited (code 1)". (T-437)
- **Web/WASM build compiles again.** `flutter build web --wasm` (and the
`test-e2e` / `ui-dev` / `ui-smoke` harness) had been broken since the
tree-sitter/PTY `dart:ffi` pivot. Every native binding (PTY, tree-sitter, the
Lua host, the Windows watchdog, the ABI/fd-inheritance probes) now sits behind
a `dart.library.ffi` conditional import with a graceful web stub, and the FNV
hash constants are dart2js-safe. A `flutter build web --wasm` compile gate was
added to CI so the fence can't silently rot. Desktop builds are unchanged — the
web target degrades (no terminal, no native git, no syntax highlighting), it
does not compromise desktop fidelity. (T-438, D-100, Q-50)
## [2.5.0] — 2026-06-14 ## [2.5.0] — 2026-06-14
+5 -22
View File
@@ -21,11 +21,14 @@
/// IO wrapper the orchestrator calls. Flutter-free by design. /// IO wrapper the orchestrator calls. Flutter-free by design.
library; library;
import 'dart:ffi' show Abi;
import 'dart:io'; import 'dart:io';
import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath; import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath;
// Web fence (T-438, D-100): `Abi.current()` (dart:ffi) is desktop-only; the web
// build gets a default dir name with no FFI introspection.
import 'native_abi_stub.dart' if (dart.library.ffi) 'native_abi_io.dart';
/// The `--allowedTools` rule that pre-approves `clide …` Bash calls for a /// The `--allowedTools` rule that pre-approves `clide …` Bash calls for a
/// hosted session (T-217), so the agent isn't prompted on every IDE call. /// hosted session (T-217), so the agent isn't prompted on every IDE call.
/// Claude Code's settings/flag syntax for a command-scoped Bash rule is /// Claude Code's settings/flag syntax for a command-scoped Bash rule is
@@ -90,26 +93,6 @@ String? resolveClideCliDir({required String? currentPath, required List<String>
return null; return null;
} }
/// The `native/<os>-<arch>/` directory name the Makefile builds the C client
/// into (e.g. `linux-x64`, `macos-arm64`) — used to find the dev-tree binary
/// when clide runs un-installed (dogfooding clide-on-clide).
String nativeClideDirName({Abi? abi}) {
switch (abi ?? Abi.current()) {
case Abi.macosArm64:
return 'macos-arm64';
case Abi.macosX64:
return 'macos-x64';
case Abi.linuxArm64:
return 'linux-arm64';
case Abi.linuxX64:
return 'linux-x64';
default:
// Windows / other — clide is desktop linux/macos today; fall back to a
// best-effort name so the probe simply misses rather than throwing.
return Platform.isMacOS ? 'macos-x64' : 'linux-x64';
}
}
/// The result of [agentBootstrap]: the env delta to overlay and the extra /// The result of [agentBootstrap]: the env delta to overlay and the extra
/// spawn args (context note + allow rule) to prepend to a session's argv. /// spawn args (context note + allow rule) to prepend to a session's argv.
class AgentBootstrap { class AgentBootstrap {
@@ -128,7 +111,7 @@ AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base})
final currentPath = (base ?? Platform.environment)['PATH'] ?? Platform.environment['PATH']; final currentPath = (base ?? Platform.environment)['PATH'] ?? Platform.environment['PATH'];
final candidates = <String>[ final candidates = <String>[
if (home != null && home.isNotEmpty) '$home/.local/bin', if (home != null && home.isNotEmpty) '$home/.local/bin',
'$workspaceRoot/native/${nativeClideDirName()}', '$workspaceRoot/native/${currentNativeDirName()}',
File(Platform.resolvedExecutable).parent.path, File(Platform.resolvedExecutable).parent.path,
]; ];
final cliDir = resolveClideCliDir(currentPath: currentPath, candidateDirs: candidates, isExecutableFile: _isExecutableFile); final cliDir = resolveClideCliDir(currentPath: currentPath, candidateDirs: candidates, isExecutableFile: _isExecutableFile);
+28
View File
@@ -0,0 +1,28 @@
/// Resolve the `native/<os>-<arch>/` directory name via FFI ABI introspection
/// (T-438 web fence, D-100). Desktop-only; the web build uses
/// [native_abi_stub.dart], so `dart:ffi` (here, only `Abi`) stays out of the
/// wasm graph.
library;
import 'dart:ffi' show Abi;
import 'dart:io' show Platform;
/// The `<os>-<arch>` dir name for the current process (e.g. `linux-x64`,
/// `macos-arm64`) — used to find a dev-tree `clide` binary when running
/// un-installed. [abi] is an injection seam for tests; production passes none.
String currentNativeDirName({Abi? abi}) {
switch (abi ?? Abi.current()) {
case Abi.macosArm64:
return 'macos-arm64';
case Abi.macosX64:
return 'macos-x64';
case Abi.linuxArm64:
return 'linux-arm64';
case Abi.linuxX64:
return 'linux-x64';
default:
// Windows / other — clide is desktop linux/macOS today; fall back to a
// best-effort name so the probe simply misses rather than throwing.
return Platform.isMacOS ? 'macos-x64' : 'linux-x64';
}
}
@@ -0,0 +1,6 @@
/// Web stub (T-438 web fence, D-100): no FFI ABI introspection on web. The
/// native dir name only matters for locating a dev-tree `clide` binary, which
/// doesn't exist on the web target — so a harmless default suffices.
library;
String currentNativeDirName() => 'linux-x64';
+8 -2
View File
@@ -130,12 +130,18 @@ String freshSessionId() {
/// same id). Expands an FNV-1a stream into 16 bytes. /// same id). Expands an FNV-1a stream into 16 bytes.
String _deterministicUuid(String seed) { String _deterministicUuid(String seed) {
final bytes = <int>[]; final bytes = <int>[];
var h = 0xcbf29ce484222325; // FNV-1a 64-bit offset basis, split into two 32-bit halves so the dart2js
// web fallback accepts it (a full 64-bit literal "can't be represented
// exactly in JavaScript" — T-438). Correct on the VM/wasm; web never derives
// a session id (no claude process there).
var h = (0xcbf29ce4 << 32) | 0x84222325;
const prime = 0x100000001b3; const prime = 0x100000001b3;
for (var i = 0; i < 16; i++) { for (var i = 0; i < 16; i++) {
for (final c in utf8.encode('$seed:$i')) { for (final c in utf8.encode('$seed:$i')) {
h ^= c; h ^= c;
h = (h * prime) & 0xFFFFFFFFFFFFFFFF; // 64-bit modular wrap is implicit on the VM/wasm; the explicit
// `& 0xFFFFFFFFFFFFFFFF` was a no-op and a dart2js-incompatible literal.
h = h * prime;
} }
bytes.add(h & 0xff); bytes.add(h & 0xff);
} }
+29
View File
@@ -0,0 +1,29 @@
/// FFI fd-inheritance probe for the testmode harness (T-438 web fence, D-100).
///
/// Desktop-only — [test_app.dart] selects [fd_check_stub.dart] on web so the
/// `dart:ffi` / `package:ffi` / libc imports stay out of the wasm graph.
library;
import 'dart:convert';
import 'dart:ffi' as ffi;
import 'dart:io';
import 'package:ffi/ffi.dart' as pkg_ffi;
import 'src/pty/ffi/libc.dart' as libc;
/// Probe whether `Process.start` inherits socket fds (the macOS question the
/// testmode harness answers). Returns a human-readable result line.
Future<String> fdInheritanceCheck() async {
final sv = pkg_ffi.calloc<ffi.Int32>(2);
libc.socketpair(1, 1, 0, sv); // AF_UNIX, SOCK_STREAM
final parent = sv[0];
final child = sv[1];
pkg_ffi.calloc.free(sv);
final proc = await Process.start('/tmp/checkfd', [], environment: {...Platform.environment, 'PTYC_SOCK_FD': '$child'});
final stderr = await proc.stderr.transform(utf8.decoder).join();
final exit = await proc.exitCode;
libc.close(parent);
libc.close(child);
return 'exit=$exit stderr=${stderr.trim()}';
}
+4
View File
@@ -0,0 +1,4 @@
/// Web stub (T-438 web fence, D-100): no FFI fd-inheritance probe on web.
library;
Future<String> fdInheritanceCheck() async => 'skipped (no FFI on web)';
+54
View File
@@ -0,0 +1,54 @@
/// Pure syntax-highlight result types + the capture-role→theme-color map
/// (T-438 web fence, D-100). No `dart:ffi`, so it is shared by the FFI-backed
/// [TreeSitterService] impl and its web stub — both expose identical data types.
library;
import 'dart:typed_data';
import 'dart:ui' show Color;
import 'package:clide/kernel/src/theme/tokens.dart';
/// Loads grammar WASM bytes for [language] (e.g. "dart" → `dart.wasm`).
/// Throws on missing or unreadable assets.
typedef GrammarBytesLoader = Future<Uint8List> Function(String language);
/// Loads the highlight query (`.scm` source) for [language], or returns
/// null if no query is bundled for it.
typedef GrammarQueryLoader = Future<String?> Function(String language);
class SyntaxSpan {
const SyntaxSpan({required this.start, required this.end, required this.role});
final int start;
final int end;
final String role;
}
class SyntaxResult {
const SyntaxResult(this.spans);
final List<SyntaxSpan> spans;
static const empty = SyntaxResult([]);
}
/// Map a tree-sitter capture [role] to a theme color.
Color syntaxColorForRole(String role, SurfaceTokens tokens) {
return switch (role) {
'keyword' || 'repeat' || 'conditional' || 'include' || 'exception' || 'operator' => tokens.syntaxKeyword,
'type' || 'type.builtin' || 'constructor' => tokens.syntaxType,
'string' || 'string.special' => tokens.syntaxString,
'number' || 'float' || 'boolean' => tokens.syntaxNumber,
'comment' => tokens.syntaxComment,
'function' || 'function.builtin' || 'function.method' || 'method' => tokens.syntaxMethod,
'punctuation.bracket' || 'punctuation.delimiter' || 'punctuation.special' => tokens.syntaxPunct,
'variable' || 'variable.builtin' || 'variable.parameter' => tokens.globalForeground,
'property' || 'field' => tokens.syntaxMethod,
'constant' || 'constant.builtin' => tokens.syntaxNumber,
'tag' || 'attribute' => tokens.syntaxKeyword,
'namespace' || 'module' => tokens.syntaxType,
'text.title' => tokens.syntaxKeyword,
'text.literal' || 'text.reference' || 'text.uri' => tokens.syntaxString,
'text.emphasis' || 'text.strong' => tokens.syntaxType,
_ => tokens.globalForeground,
};
}
@@ -0,0 +1,8 @@
/// Desktop tree-sitter bootstrap (T-438 web fence, D-100): dlopen the vendored
/// libtree-sitter once at startup. The web build uses [tree_sitter_boot_stub.dart].
library;
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
/// Initialize the tree-sitter library; returns false if it can't be loaded.
bool initTreeSitter() => TreeSitterLib.init();
@@ -0,0 +1,4 @@
/// Web stub (T-438 web fence, D-100): no tree-sitter FFI to initialize.
library;
bool initTreeSitter() => false;
+5 -301
View File
@@ -1,303 +1,7 @@
/// Platform facade for the tree-sitter highlighter (T-438 web fence, D-100):
/// the FFI-backed [TreeSitterService] on desktop, a no-op stub on web. Both
/// re-export the shared [SyntaxSpan]/[SyntaxResult] types and `colorForRole`,
/// so consumers import this file unchanged.
library; library;
import 'dart:convert' show utf8; export 'tree_sitter_service_stub.dart' if (dart.library.ffi) 'tree_sitter_service_ffi.dart';
import 'dart:ffi';
import 'dart:ui' show Color;
import 'package:clide/kernel/src/syntax/language_map.dart';
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:ffi/ffi.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' show rootBundle;
/// Loads grammar WASM bytes for [language] (e.g. "dart" → `dart.wasm`).
/// Throws on missing or unreadable assets.
typedef GrammarBytesLoader = Future<Uint8List> Function(String language);
/// Loads the highlight query (`.scm` source) for [language], or returns
/// null if no query is bundled for it.
typedef GrammarQueryLoader = Future<String?> Function(String language);
class SyntaxSpan {
const SyntaxSpan({required this.start, required this.end, required this.role});
final int start;
final int end;
final String role;
}
class SyntaxResult {
const SyntaxResult(this.spans);
final List<SyntaxSpan> spans;
static const empty = SyntaxResult([]);
}
class _LoadedGrammar {
_LoadedGrammar({required this.language, required this.query, required this.captureNames});
final Pointer<Void> language;
final Pointer<TSQuery> query;
final List<String> captureNames;
}
class TreeSitterService {
static final TreeSitterService shared = TreeSitterService();
/// Production constructor: uses the dlopen'd [TreeSitterLib.instance] and
/// the Flutter [rootBundle]. Tests pass [lib] / [grammarBytes] /
/// [grammarQuery] to substitute a fake FFI surface and in-memory assets.
TreeSitterService({TreeSitterLib? lib, GrammarBytesLoader? grammarBytes, GrammarQueryLoader? grammarQuery})
: _injectedLib = lib,
_grammarBytes = grammarBytes ?? _defaultGrammarBytes,
_grammarQuery = grammarQuery ?? _defaultGrammarQuery;
final TreeSitterLib? _injectedLib;
final GrammarBytesLoader _grammarBytes;
final GrammarQueryLoader _grammarQuery;
TreeSitterLib? get _lib => _injectedLib ?? TreeSitterLib.instance;
static Future<Uint8List> _defaultGrammarBytes(String language) async {
final data = await rootBundle.load('assets/grammars/$language.wasm');
return data.buffer.asUint8List();
}
static Future<String?> _defaultGrammarQuery(String language) async {
try {
return await rootBundle.loadString('assets/queries/$language.scm');
} catch (_) {
return null;
}
}
final Map<String, _LoadedGrammar> _grammars = {};
final Set<String> _unavailable = {};
Pointer<TSWasmStore>? _store;
Pointer<TSParser>? _parser;
Pointer<TSQueryCursor>? _cursor;
bool _initDone = false;
bool _init() {
if (_initDone) return _parser != null;
_initDone = true;
final lib = _lib;
if (lib == null) return false;
final engine = lib.wasmEngineNew();
if (engine == nullptr) return false;
final error = calloc<TSWasmError>();
_store = lib.wasmStoreNew(engine, error);
lib.wasmEngineDelete(engine);
if (_store == null || _store == nullptr) {
calloc.free(error);
return false;
}
calloc.free(error);
_parser = lib.parserNew();
if (_parser == null || _parser == nullptr) return false;
lib.parserSetWasmStore(_parser!, _store!);
_cursor = lib.queryCursorNew();
return true;
}
Future<_LoadedGrammar?> _loadGrammar(String language) async {
if (_unavailable.contains(language)) return null;
final cached = _grammars[language];
if (cached != null) return cached;
if (!_init()) {
_unavailable.add(language);
return null;
}
final lib = _lib!;
try {
// Load grammar WASM bytes.
final wasmBytes = await _grammarBytes(language);
// Load into WASM store.
final nameNative = language.toNativeUtf8();
final wasmNative = calloc<Uint8>(wasmBytes.length);
wasmNative.asTypedList(wasmBytes.length).setAll(0, wasmBytes);
final error = calloc<TSWasmError>();
final lang = lib.wasmStoreLoadLanguage(_store!, nameNative.cast(), wasmNative, wasmBytes.length, error);
calloc.free(wasmNative);
calloc.free(nameNative);
if (lang == nullptr) {
final msg = error.ref.message;
if (msg != nullptr) calloc.free(msg);
calloc.free(error);
_unavailable.add(language);
return null;
}
calloc.free(error);
// Load highlight query.
final querySource = await _grammarQuery(language);
Pointer<TSQuery> query = nullptr;
List<String> captureNames = [];
if (querySource != null) {
final queryNative = querySource.toNativeUtf8();
final queryLen = utf8.encode(querySource).length;
final errorOffset = calloc<Uint32>();
final errorType = calloc<Int32>();
query = lib.queryNew(lang, queryNative.cast(), queryLen, errorOffset, errorType);
calloc.free(queryNative);
calloc.free(errorOffset);
calloc.free(errorType);
if (query != nullptr) {
final count = lib.queryCaptureCount(query);
final lenOut = calloc<Uint32>();
for (var i = 0; i < count; i++) {
final namePtr = lib.queryCaptureNameForId(query, i, lenOut);
final len = lenOut.value;
captureNames.add(namePtr.cast<Utf8>().toDartString(length: len));
}
calloc.free(lenOut);
}
}
final grammar = _LoadedGrammar(language: lang, query: query, captureNames: captureNames);
_grammars[language] = grammar;
return grammar;
} catch (_) {
_unavailable.add(language);
return null;
}
}
Future<bool> hasGrammar(String path) async {
final lang = grammarForPath(path);
if (lang == null) return false;
return (await _loadGrammar(lang)) != null;
}
Future<String?> languageFor(String path) async {
final lang = grammarForPath(path);
if (lang == null) return null;
return (await _loadGrammar(lang)) != null ? lang : null;
}
List<String> get loadedLanguages => _grammars.keys.toList();
Future<SyntaxResult> highlight(String path, String source) async {
final lang = grammarForPath(path);
if (lang == null) return SyntaxResult.empty;
final grammar = await _loadGrammar(lang);
if (grammar == null || grammar.query == nullptr) {
return SyntaxResult.empty;
}
final lib = _lib!;
final parser = _parser!;
final cursor = _cursor!;
// Set language on parser for this parse.
lib.parserSetLanguage(parser, grammar.language);
// Parse source.
final sourceNative = source.toNativeUtf8();
final sourceLen = utf8.encode(source).length;
final tree = lib.parserParseString(parser, nullptr, sourceNative.cast(), sourceLen);
if (tree == nullptr) {
calloc.free(sourceNative);
return SyntaxResult.empty;
}
final root = lib.treeRootNode(tree);
// Run highlight query.
lib.queryCursorExec(cursor, grammar.query, root);
final match = calloc<TSQueryMatch>();
final spans = <SyntaxSpan>[];
while (lib.queryCursorNextMatch(cursor, match)) {
final m = match.ref;
for (var i = 0; i < m.captureCount; i++) {
final cap = m.captures[i];
final captureIndex = cap.index;
if (captureIndex < grammar.captureNames.length) {
spans.add(SyntaxSpan(start: lib.nodeStartByte(cap.node), end: lib.nodeEndByte(cap.node), role: grammar.captureNames[captureIndex]));
}
}
}
calloc.free(match);
lib.treeDelete(tree);
calloc.free(sourceNative);
return SyntaxResult(spans);
}
void dispose() {
final lib = _lib;
if (lib == null) return;
for (final grammar in _grammars.values) {
if (grammar.query != nullptr) lib.queryDelete(grammar.query);
}
_grammars.clear();
if (_cursor != null && _cursor != nullptr) lib.queryCursorDelete(_cursor!);
// Parser and WASM store are cleaned up together — deleting the parser
// does not delete the store, but the store owns the languages.
if (_parser != null && _parser != nullptr) lib.parserDelete(_parser!);
if (_store != null && _store != nullptr) lib.wasmStoreDelete(_store!);
_parser = null;
_store = null;
_cursor = null;
_unavailable.clear();
}
/// Resets the service to a pre-init state. Tests use this to re-exercise
/// `_init()` without constructing a new singleton; production code never
/// needs it.
@visibleForTesting
void resetForTests() {
dispose();
_initDone = false;
}
static Color colorForRole(String role, SurfaceTokens tokens) {
return switch (role) {
'keyword' || 'repeat' || 'conditional' || 'include' || 'exception' || 'operator' => tokens.syntaxKeyword,
'type' || 'type.builtin' || 'constructor' => tokens.syntaxType,
'string' || 'string.special' => tokens.syntaxString,
'number' || 'float' || 'boolean' => tokens.syntaxNumber,
'comment' => tokens.syntaxComment,
'function' || 'function.builtin' || 'function.method' || 'method' => tokens.syntaxMethod,
'punctuation.bracket' || 'punctuation.delimiter' || 'punctuation.special' => tokens.syntaxPunct,
'variable' || 'variable.builtin' || 'variable.parameter' => tokens.globalForeground,
'property' || 'field' => tokens.syntaxMethod,
'constant' || 'constant.builtin' => tokens.syntaxNumber,
'tag' || 'attribute' => tokens.syntaxKeyword,
'namespace' || 'module' => tokens.syntaxType,
'text.title' => tokens.syntaxKeyword,
'text.literal' || 'text.reference' || 'text.uri' => tokens.syntaxString,
'text.emphasis' || 'text.strong' => tokens.syntaxType,
_ => tokens.globalForeground,
};
}
}
@@ -0,0 +1,269 @@
/// FFI-backed tree-sitter highlighter (T-438 web fence, D-100). Selected by the
/// [tree_sitter_service.dart] facade when `dart.library.ffi` is available; the
/// web build gets [tree_sitter_service_stub.dart] instead. Pure result types
/// live in [syntax_result.dart] (re-exported so consumers import only the
/// facade).
library;
import 'dart:convert' show utf8;
import 'dart:ffi';
import 'dart:ui' show Color;
import 'package:clide/kernel/src/syntax/language_map.dart';
import 'package:clide/kernel/src/syntax/syntax_result.dart';
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:ffi/ffi.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' show rootBundle;
export 'package:clide/kernel/src/syntax/syntax_result.dart';
class _LoadedGrammar {
_LoadedGrammar({required this.language, required this.query, required this.captureNames});
final Pointer<Void> language;
final Pointer<TSQuery> query;
final List<String> captureNames;
}
class TreeSitterService {
static final TreeSitterService shared = TreeSitterService();
/// Production constructor: uses the dlopen'd [TreeSitterLib.instance] and
/// the Flutter [rootBundle]. Tests pass [lib] / [grammarBytes] /
/// [grammarQuery] to substitute a fake FFI surface and in-memory assets.
TreeSitterService({TreeSitterLib? lib, GrammarBytesLoader? grammarBytes, GrammarQueryLoader? grammarQuery})
: _injectedLib = lib,
_grammarBytes = grammarBytes ?? _defaultGrammarBytes,
_grammarQuery = grammarQuery ?? _defaultGrammarQuery;
final TreeSitterLib? _injectedLib;
final GrammarBytesLoader _grammarBytes;
final GrammarQueryLoader _grammarQuery;
TreeSitterLib? get _lib => _injectedLib ?? TreeSitterLib.instance;
static Future<Uint8List> _defaultGrammarBytes(String language) async {
final data = await rootBundle.load('assets/grammars/$language.wasm');
return data.buffer.asUint8List();
}
static Future<String?> _defaultGrammarQuery(String language) async {
try {
return await rootBundle.loadString('assets/queries/$language.scm');
} catch (_) {
return null;
}
}
final Map<String, _LoadedGrammar> _grammars = {};
final Set<String> _unavailable = {};
Pointer<TSWasmStore>? _store;
Pointer<TSParser>? _parser;
Pointer<TSQueryCursor>? _cursor;
bool _initDone = false;
bool _init() {
if (_initDone) return _parser != null;
_initDone = true;
final lib = _lib;
if (lib == null) return false;
final engine = lib.wasmEngineNew();
if (engine == nullptr) return false;
final error = calloc<TSWasmError>();
_store = lib.wasmStoreNew(engine, error);
lib.wasmEngineDelete(engine);
if (_store == null || _store == nullptr) {
calloc.free(error);
return false;
}
calloc.free(error);
_parser = lib.parserNew();
if (_parser == null || _parser == nullptr) return false;
lib.parserSetWasmStore(_parser!, _store!);
_cursor = lib.queryCursorNew();
return true;
}
Future<_LoadedGrammar?> _loadGrammar(String language) async {
if (_unavailable.contains(language)) return null;
final cached = _grammars[language];
if (cached != null) return cached;
if (!_init()) {
_unavailable.add(language);
return null;
}
final lib = _lib!;
try {
// Load grammar WASM bytes.
final wasmBytes = await _grammarBytes(language);
// Load into WASM store.
final nameNative = language.toNativeUtf8();
final wasmNative = calloc<Uint8>(wasmBytes.length);
wasmNative.asTypedList(wasmBytes.length).setAll(0, wasmBytes);
final error = calloc<TSWasmError>();
final lang = lib.wasmStoreLoadLanguage(_store!, nameNative.cast(), wasmNative, wasmBytes.length, error);
calloc.free(wasmNative);
calloc.free(nameNative);
if (lang == nullptr) {
final msg = error.ref.message;
if (msg != nullptr) calloc.free(msg);
calloc.free(error);
_unavailable.add(language);
return null;
}
calloc.free(error);
// Load highlight query.
final querySource = await _grammarQuery(language);
Pointer<TSQuery> query = nullptr;
List<String> captureNames = [];
if (querySource != null) {
final queryNative = querySource.toNativeUtf8();
final queryLen = utf8.encode(querySource).length;
final errorOffset = calloc<Uint32>();
final errorType = calloc<Int32>();
query = lib.queryNew(lang, queryNative.cast(), queryLen, errorOffset, errorType);
calloc.free(queryNative);
calloc.free(errorOffset);
calloc.free(errorType);
if (query != nullptr) {
final count = lib.queryCaptureCount(query);
final lenOut = calloc<Uint32>();
for (var i = 0; i < count; i++) {
final namePtr = lib.queryCaptureNameForId(query, i, lenOut);
final len = lenOut.value;
captureNames.add(namePtr.cast<Utf8>().toDartString(length: len));
}
calloc.free(lenOut);
}
}
final grammar = _LoadedGrammar(language: lang, query: query, captureNames: captureNames);
_grammars[language] = grammar;
return grammar;
} catch (_) {
_unavailable.add(language);
return null;
}
}
Future<bool> hasGrammar(String path) async {
final lang = grammarForPath(path);
if (lang == null) return false;
return (await _loadGrammar(lang)) != null;
}
Future<String?> languageFor(String path) async {
final lang = grammarForPath(path);
if (lang == null) return null;
return (await _loadGrammar(lang)) != null ? lang : null;
}
List<String> get loadedLanguages => _grammars.keys.toList();
Future<SyntaxResult> highlight(String path, String source) async {
final lang = grammarForPath(path);
if (lang == null) return SyntaxResult.empty;
final grammar = await _loadGrammar(lang);
if (grammar == null || grammar.query == nullptr) {
return SyntaxResult.empty;
}
final lib = _lib!;
final parser = _parser!;
final cursor = _cursor!;
// Set language on parser for this parse.
lib.parserSetLanguage(parser, grammar.language);
// Parse source.
final sourceNative = source.toNativeUtf8();
final sourceLen = utf8.encode(source).length;
final tree = lib.parserParseString(parser, nullptr, sourceNative.cast(), sourceLen);
if (tree == nullptr) {
calloc.free(sourceNative);
return SyntaxResult.empty;
}
final root = lib.treeRootNode(tree);
// Run highlight query.
lib.queryCursorExec(cursor, grammar.query, root);
final match = calloc<TSQueryMatch>();
final spans = <SyntaxSpan>[];
while (lib.queryCursorNextMatch(cursor, match)) {
final m = match.ref;
for (var i = 0; i < m.captureCount; i++) {
final cap = m.captures[i];
final captureIndex = cap.index;
if (captureIndex < grammar.captureNames.length) {
spans.add(SyntaxSpan(start: lib.nodeStartByte(cap.node), end: lib.nodeEndByte(cap.node), role: grammar.captureNames[captureIndex]));
}
}
}
calloc.free(match);
lib.treeDelete(tree);
calloc.free(sourceNative);
return SyntaxResult(spans);
}
void dispose() {
final lib = _lib;
if (lib == null) return;
for (final grammar in _grammars.values) {
if (grammar.query != nullptr) lib.queryDelete(grammar.query);
}
_grammars.clear();
if (_cursor != null && _cursor != nullptr) lib.queryCursorDelete(_cursor!);
// Parser and WASM store are cleaned up together — deleting the parser
// does not delete the store, but the store owns the languages.
if (_parser != null && _parser != nullptr) lib.parserDelete(_parser!);
if (_store != null && _store != nullptr) lib.wasmStoreDelete(_store!);
_parser = null;
_store = null;
_cursor = null;
_unavailable.clear();
}
/// Resets the service to a pre-init state. Tests use this to re-exercise
/// `_init()` without constructing a new singleton; production code never
/// needs it.
@visibleForTesting
void resetForTests() {
dispose();
_initDone = false;
}
static Color colorForRole(String role, SurfaceTokens tokens) => syntaxColorForRole(role, tokens);
}
@@ -0,0 +1,27 @@
/// Web stub for [TreeSitterService] (T-438 web fence, D-100): no tree-sitter
/// FFI on web, so highlighting is a no-op — every query returns no spans and
/// the editor / code block render plain text. Mirrors the FFI impl's public
/// API (and re-exports the shared result types) so the facade is transparent.
library;
import 'dart:ui' show Color;
import 'package:clide/kernel/src/syntax/syntax_result.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
export 'package:clide/kernel/src/syntax/syntax_result.dart';
class TreeSitterService {
static final TreeSitterService shared = TreeSitterService();
TreeSitterService();
Future<bool> hasGrammar(String path) async => false;
Future<String?> languageFor(String path) async => null;
List<String> get loadedLanguages => const [];
Future<SyntaxResult> highlight(String path, String source) async => SyntaxResult.empty;
void dispose() {}
void resetForTests() {}
static Color colorForRole(String role, SurfaceTokens tokens) => syntaxColorForRole(role, tokens);
}
+3 -1
View File
@@ -25,7 +25,9 @@ library;
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'watchdog_windows.dart'; // Web fence (T-438, D-100): the FFI-backed Windows sampler is reachable only
// when `dart.library.ffi` is available; the web build gets an all-`-1` stub.
import 'watchdog_windows_stub.dart' if (dart.library.ffi) 'watchdog_windows.dart';
/// One resource sample of the current process. A field of `-1` means "not /// One resource sample of the current process. A field of `-1` means "not
/// available on this platform or the probe failed" — never an error. /// available on this platform or the probe failed" — never an error.
+15
View File
@@ -0,0 +1,15 @@
/// Web/non-FFI stub for the Windows resource sampler (T-438 web fence, D-100).
///
/// [watchdog.dart] selects this when `dart.library.ffi` is absent, keeping the
/// `kernel32`/`psapi` FFI bindings out of the wasm graph. The watchdog isolate
/// never spawns on web, and `forPlatform()` never returns the Windows sampler
/// there — this exists only to satisfy the import. Returns an all-unavailable
/// sample (every field `-1`) if ever called.
library;
import 'watchdog.dart';
class WindowsResourceSampler implements ResourceSampler {
@override
ResourceSample sample() => const ResourceSample();
}
+4 -2
View File
@@ -59,7 +59,9 @@ import 'package:clide/src/ipc/server.dart';
import 'package:clide/src/panes/event_sink.dart'; import 'package:clide/src/panes/event_sink.dart';
import 'package:clide/src/panes/registry.dart'; import 'package:clide/src/panes/registry.dart';
import 'package:clide/src/pql/client.dart'; import 'package:clide/src/pql/client.dart';
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart'; // Web fence (T-438, D-100): tree-sitter init is FFI-backed on desktop, a no-op
// on web (highlighting degrades to plain text there).
import 'package:clide/kernel/src/syntax/tree_sitter_boot_stub.dart' if (dart.library.ffi) 'package:clide/kernel/src/syntax/tree_sitter_boot_io.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' show rootBundle; import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@@ -81,7 +83,7 @@ Future<void> main() async {
binding.ensureSemantics(); binding.ensureSemantics();
TreeSitterLib.init(); initTreeSitter();
final appDir = await _resolveAppDir(); final appDir = await _resolveAppDir();
final themes = await _loadBundledThemes(); final themes = await _loadBundledThemes();
+5 -1
View File
@@ -101,7 +101,11 @@ String logDirectory([Map<String, String>? env]) {
String fnv1a64Hex(String s) { String fnv1a64Hex(String s) {
// Desktop-only (the IPC server is desktop-only per D-56). Dart VM // Desktop-only (the IPC server is desktop-only per D-56). Dart VM
// ints are 64-bit; arithmetic wraps modulo 2^64 naturally. // ints are 64-bit; arithmetic wraps modulo 2^64 naturally.
var h = 0xcbf29ce484222325; // FNV-1a 64-bit offset basis. Split into two 32-bit halves so the dart2js
// web fallback accepts it — a 0xcbf2…2325 literal "can't be represented
// exactly in JavaScript" (T-438). Correct on the VM/wasm (int64); the web
// build never hashes a socket path (the IPC server is desktop-only).
var h = (0xcbf29ce4 << 32) | 0x84222325;
const prime = 0x100000001b3; const prime = 0x100000001b3;
final bytes = utf8.encode(s); final bytes = utf8.encode(s);
for (final b in bytes) { for (final b in bytes) {
+46
View File
@@ -0,0 +1,46 @@
/// Desktop PTY backend selection (T-438 web fence, D-100).
///
/// [pty_session.dart] imports this only when `dart.library.ffi` is available;
/// the web build gets [pty_backend_web.dart] instead, so the FFI-backed
/// [NativePty]/[WindowsPty] never enter the wasm compile graph.
library;
import 'dart:io' show Platform;
import 'native_pty.dart';
import 'pty_log.dart';
import 'pty_session.dart';
import 'windows_pty.dart';
/// Spawn a PTY child using the platform backend — ConPTY on Windows
/// ([WindowsPty]), `posix_openpt` + `posix_spawn` elsewhere ([NativePty]).
PtySession startPtyBackend({
required String executable,
List<String> arguments = const [],
required int columns,
required int rows,
String? workingDirectory,
Map<String, String> environment = const {},
PtyLog log = PtyLog.none,
}) {
if (Platform.isWindows) {
return WindowsPty.start(
executable: executable,
arguments: arguments,
columns: columns,
rows: rows,
workingDirectory: workingDirectory,
environment: environment,
log: log,
);
}
return NativePty.start(
executable: executable,
arguments: arguments,
columns: columns,
rows: rows,
workingDirectory: workingDirectory,
environment: environment,
log: log,
);
}
+21
View File
@@ -0,0 +1,21 @@
/// Web stub for the PTY backend (T-438 web fence, D-100).
///
/// The web/WASM target has no PTY — spawning a child under a pseudo-terminal is
/// desktop-only (it needs `dart:ffi`). [pty_session.dart] selects this when
/// `dart.library.ffi` is absent, keeping [NativePty]/[WindowsPty] out of the
/// wasm graph. The web build is a UI/e2e surface, not a functional desktop
/// replacement, so a terminal is never spawned there; calling this is a bug.
library;
import 'pty_log.dart';
import 'pty_session.dart';
PtySession startPtyBackend({
required String executable,
List<String> arguments = const [],
required int columns,
required int rows,
String? workingDirectory,
Map<String, String> environment = const {},
PtyLog log = PtyLog.none,
}) => throw UnsupportedError('PTY sessions are not available on the web target.');
+13 -25
View File
@@ -8,12 +8,13 @@
/// in → EOF on child exit → close() reaps. /// in → EOF on child exit → close() reaps.
library; library;
import 'dart:io' show Platform;
import 'dart:typed_data'; import 'dart:typed_data';
import 'native_pty.dart'; // Web fence (T-438, D-100): the FFI-backed backends are reachable only when
// `dart.library.ffi` is available; the web build gets a throwing stub, so
// `dart:ffi` never enters the wasm compile graph.
import 'pty_backend_web.dart' if (dart.library.ffi) 'pty_backend_io.dart';
import 'pty_log.dart'; import 'pty_log.dart';
import 'windows_pty.dart';
abstract interface class PtySession { abstract interface class PtySession {
/// OS process id of the spawned child. /// OS process id of the spawned child.
@@ -51,25 +52,12 @@ PtySession startPtySession({
String? workingDirectory, String? workingDirectory,
Map<String, String> environment = const {}, Map<String, String> environment = const {},
PtyLog log = PtyLog.none, PtyLog log = PtyLog.none,
}) { }) => startPtyBackend(
if (Platform.isWindows) { executable: executable,
return WindowsPty.start( arguments: arguments,
executable: executable, columns: columns,
arguments: arguments, rows: rows,
columns: columns, workingDirectory: workingDirectory,
rows: rows, environment: environment,
workingDirectory: workingDirectory, log: log,
environment: environment, );
log: log,
);
}
return NativePty.start(
executable: executable,
arguments: arguments,
columns: columns,
rows: rows,
workingDirectory: workingDirectory,
environment: environment,
log: log,
);
}
+7 -17
View File
@@ -25,10 +25,11 @@ import 'builtin/files/files.dart';
import 'builtin/git/git.dart'; import 'builtin/git/git.dart';
import 'builtin/terminal/terminal.dart'; import 'builtin/terminal/terminal.dart';
import 'extension/extension.dart' show ClideExtension; import 'extension/extension.dart' show ClideExtension;
import 'dart:ffi' as ffi;
import 'package:ffi/ffi.dart' as pkg_ffi;
import 'kernel/kernel.dart'; import 'kernel/kernel.dart';
import 'src/pty/ffi/libc.dart' as libc;
// Web fence (T-438, D-100): the FFI fd-inheritance probe is desktop-only; the
// web build gets a no-op stub so dart:ffi / package:ffi / libc stay out.
import 'fd_check_stub.dart' if (dart.library.ffi) 'fd_check_io.dart';
import 'src/daemon/pane_commands.dart'; import 'src/daemon/pane_commands.dart';
import 'src/ipc/envelope.dart'; import 'src/ipc/envelope.dart';
import 'src/ipc/paths.dart' show logDirectory; import 'src/ipc/paths.dart' show logDirectory;
@@ -375,20 +376,9 @@ class _ClideTestAppState extends State<ClideTestApp> {
return output.isNotEmpty ? 'got ${output.length} chars' : 'no output (0 chars)'; return output.isNotEmpty ? 'got ${output.length} chars' : 'no output (0 chars)';
}); });
// Test: does Dart's Process.start inherit socket fds on macOS? // Test: does Dart's Process.start inherit socket fds on macOS? (T-438: the
await _testAsync('fd inheritance check', () async { // FFI body lives in fd_check_io.dart so the web build can stub it out.)
final sv = pkg_ffi.calloc<ffi.Int32>(2); await _testAsync('fd inheritance check', fdInheritanceCheck);
libc.socketpair(1, 1, 0, sv); // AF_UNIX, SOCK_STREAM
final parent = sv[0];
final child = sv[1];
pkg_ffi.calloc.free(sv);
final proc = await Process.start('/tmp/checkfd', [], environment: {...Platform.environment, 'PTYC_SOCK_FD': '$child'});
final stderr = await proc.stderr.transform(utf8.decoder).join();
final exit = await proc.exitCode;
libc.close(parent);
libc.close(child);
return 'exit=$exit stderr=${stderr.trim()}';
});
_say(''); _say('');
} }
@@ -8,7 +8,9 @@ import 'dart:ffi';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart'; import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
import 'package:clide/kernel/src/syntax/tree_sitter_service.dart'; // The FFI impl directly (not the facade): these tests inject a fake
// TreeSitterLib via the FFI-only constructor params (T-438).
import 'package:clide/kernel/src/syntax/tree_sitter_service_ffi.dart';
import 'package:ffi/ffi.dart'; import 'package:ffi/ffi.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -13,7 +13,9 @@ import 'dart:ffi';
import 'dart:io'; import 'dart:io';
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart'; import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
import 'package:clide/kernel/src/syntax/tree_sitter_service.dart'; // The FFI impl directly (not the facade): the smoke test injects a fake
// TreeSitterLib via the FFI-only constructor params (T-438).
import 'package:clide/kernel/src/syntax/tree_sitter_service_ffi.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
const _libPath = 'native/linux-x64/libtree-sitter.so'; const _libPath = 'native/linux-x64/libtree-sitter.so';