From a6eca2561b6a67c6108f80adf479f2afe9583bb0 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 7 May 2026 18:40:01 +0200 Subject: [PATCH] remove dissolved daemon, retire ptyc, fix golden cross-platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete three overdue cleanups discovered during macOS health check: D-56 daemon dissolution: delete bin/clide.dart, DaemonServer, and orphaned tests (test/cli/, subprocess_test, in_process_test). Update stale "clide --daemon" references in i18n catalogs, error messages, editor_commands, CI scripts, and decision records. ptyc retirement: delete ptyc/ source tree, PtySession, scm_rights. Remove from Toolchain resolution, ToolCheck gate, backend serialization, testmode harness, Makefile, CI, and sandbox entitlements. PTY spawning uses NativePty (Dart FFI forkpty) since the terminal was absorbed in-tree. D-5 amended. Golden tests: wire the existing but never-applied clideGoldenConfig via flutter_test_config.dart. Disable CI goldens (Skia anti-aliasing differs between macOS/Linux even with Ahem). Keep platform-keyed goldens only — goldens/linux/ and goldens/macos/ each run on their own OS. Test suite: 826 pass, 0 fail on macOS (was 829 pass, 11 fail). Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 4 - .pql/pql-plan.json | 350 ++++++------- CHANGELOG.md | 20 + CLAUDE.md | 17 +- Makefile | 34 +- bin/clide.dart | 434 ---------------- ci/test_core.sh | 8 +- ci/test_e2e.sh | 10 +- decisions/architecture.md | 3 +- decisions/testing.md | 2 +- lib/builtin/claude/src/claude_pane.dart | 2 +- lib/builtin/claude/src/tmux_session.dart | 2 +- lib/builtin/terminal/src/terminal_pane.dart | 2 +- lib/clide.dart | 15 +- lib/kernel/src/backend.dart | 2 - lib/kernel/src/backend_entry.dart | 7 +- .../catalog/builtin.ipc-status_en_us.json | 4 +- .../i18n/catalog/builtin.terminal_en_us.json | 2 +- lib/kernel/src/ipc/client.dart | 1 - lib/kernel/src/tool_check.dart | 10 +- lib/kernel/src/toolchain.dart | 23 - lib/lua/src/host.dart | 2 +- lib/src/daemon/editor_commands.dart | 2 +- lib/src/ipc/server.dart | 144 ------ lib/src/panes/pane.dart | 4 +- lib/src/panes/registry.dart | 10 +- lib/src/pty/env.dart | 8 +- lib/src/pty/errors.dart | 6 +- lib/src/pty/ffi/libc.dart | 6 +- lib/src/pty/ffi/scm_rights.dart | 122 ----- lib/src/pty/native_pty.dart | 7 +- lib/src/pty/session.dart | 442 ---------------- lib/test_app.dart | 102 ---- .../Runner/DebugProfile.entitlements.template | 1 - ptyc/Makefile | 27 - ptyc/README.md | 127 ----- ptyc/ptyc.c | 479 ------------------ ptyc/test_ptyc.sh | 145 ------ test/cli/shortcuts_test.dart | 158 ------ test/daemon/in_process_test.dart | 113 ----- test/daemon/pane_commands_test.dart | 9 - test/daemon/subprocess_test.dart | 81 --- test/goldens/flutter_test_config.dart | 12 + test/goldens/goldens/ci/clide_button.png | Bin 2391 -> 0 bytes test/goldens/goldens/ci/clide_icon.png | Bin 5013 -> 0 bytes test/goldens/goldens/ci/clide_tab_bar.png | Bin 809 -> 0 bytes test/goldens/goldens/macos/clide_button.png | Bin 0 -> 17735 bytes test/goldens/goldens/macos/clide_icon.png | Bin 0 -> 12420 bytes test/goldens/goldens/macos/clide_tab_bar.png | Bin 0 -> 8363 bytes test/helpers/golden_harness.dart | 21 +- test/panes/registry_test.dart | 4 +- 51 files changed, 265 insertions(+), 2719 deletions(-) delete mode 100644 bin/clide.dart delete mode 100644 lib/src/ipc/server.dart delete mode 100644 lib/src/pty/ffi/scm_rights.dart delete mode 100644 lib/src/pty/session.dart delete mode 100644 ptyc/Makefile delete mode 100644 ptyc/README.md delete mode 100644 ptyc/ptyc.c delete mode 100755 ptyc/test_ptyc.sh delete mode 100644 test/cli/shortcuts_test.dart delete mode 100644 test/daemon/in_process_test.dart delete mode 100644 test/daemon/subprocess_test.dart create mode 100644 test/goldens/flutter_test_config.dart delete mode 100644 test/goldens/goldens/ci/clide_button.png delete mode 100644 test/goldens/goldens/ci/clide_icon.png delete mode 100644 test/goldens/goldens/ci/clide_tab_bar.png create mode 100644 test/goldens/goldens/macos/clide_button.png create mode 100644 test/goldens/goldens/macos/clide_icon.png create mode 100644 test/goldens/goldens/macos/clide_tab_bar.png diff --git a/.gitignore b/.gitignore index 29dcd990..83888882 100644 --- a/.gitignore +++ b/.gitignore @@ -26,10 +26,6 @@ tools/ui/test-results/ tools/ui/playwright-report/ tools/ui/.serve.pid -# -- ptyc (C supporter tool) -------------------------------------------- -/ptyc/bin/ -/ptyc/*.o - # -- dugite-native (bundled git, downloaded at build time) --------------- /native/dugite/ diff --git a/.pql/pql-plan.json b/.pql/pql-plan.json index 3820fe45..296163ec 100644 --- a/.pql/pql-plan.json +++ b/.pql/pql-plan.json @@ -1,5 +1,5 @@ { - "exported_at": "2026-05-07T06:49:09Z", + "exported_at": "2026-05-07T16:40:01Z", "decisions": [ { "id": "D-1", @@ -9,7 +9,7 @@ "status": "active", "date": "2026-04-20", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-3", @@ -19,7 +19,7 @@ "status": "active", "date": "2026-04-20", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-4", @@ -29,7 +29,7 @@ "status": "active", "date": "2026-04-20", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-5", @@ -39,7 +39,7 @@ "status": "active", "date": "2026-04-20", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-6", @@ -49,7 +49,7 @@ "status": "active", "date": "2026-04-20", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-7", @@ -59,7 +59,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-8", @@ -69,7 +69,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-9", @@ -79,7 +79,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-10", @@ -89,7 +89,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-11", @@ -99,7 +99,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-12", @@ -109,7 +109,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-13", @@ -119,7 +119,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-14", @@ -129,7 +129,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-15", @@ -139,7 +139,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/extensions.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-16", @@ -149,7 +149,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/extensions.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-17", @@ -159,7 +159,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/extensions.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-18", @@ -169,7 +169,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/extensions.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-19", @@ -179,7 +179,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/extensions.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-20", @@ -189,7 +189,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/accessibility.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-21", @@ -199,7 +199,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/accessibility.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-22", @@ -209,7 +209,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/accessibility.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-23", @@ -219,7 +219,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/testing.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-24", @@ -229,7 +229,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/testing.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-25", @@ -239,7 +239,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/testing.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-26", @@ -249,7 +249,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/testing.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-27", @@ -259,7 +259,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/testing.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-28", @@ -269,7 +269,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/testing.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-29", @@ -279,7 +279,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/testing.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-30", @@ -289,7 +289,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/testing.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-31", @@ -299,7 +299,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/tooling.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-32", @@ -309,7 +309,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/tooling.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-33", @@ -319,7 +319,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/tooling.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-34", @@ -329,7 +329,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/process.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-35", @@ -339,7 +339,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/process.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-36", @@ -349,7 +349,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/process.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-37", @@ -359,7 +359,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/process.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-38", @@ -369,7 +369,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/process.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-39", @@ -379,7 +379,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/process.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-40", @@ -389,7 +389,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/process.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-41", @@ -399,7 +399,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-42", @@ -409,7 +409,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/tooling.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-43", @@ -419,7 +419,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-44", @@ -429,7 +429,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-45", @@ -439,7 +439,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-46", @@ -449,7 +449,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/extensions.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-47", @@ -459,7 +459,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-48", @@ -469,7 +469,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-49", @@ -479,7 +479,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-50", @@ -489,7 +489,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-51", @@ -499,7 +499,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-52", @@ -509,7 +509,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-53", @@ -519,7 +519,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-54", @@ -529,7 +529,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-55", @@ -539,7 +539,7 @@ "status": "active", "date": "2026-04-23", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-56", @@ -549,7 +549,7 @@ "status": "active", "date": "2026-04-23", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-57", @@ -559,7 +559,7 @@ "status": "active", "date": "2026-04-23", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-58", @@ -569,7 +569,7 @@ "status": "active", "date": "2026-04-23", "file_path": "decisions/tooling.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-59", @@ -579,7 +579,7 @@ "status": "active", "date": "2026-04-25", "file_path": "decisions/tooling.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-60", @@ -589,7 +589,7 @@ "status": "active", "date": "2026-04-26", "file_path": "decisions/tooling.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-61", @@ -599,7 +599,7 @@ "status": "active", "date": "2026-04-26", "file_path": "decisions/tooling.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-62", @@ -609,7 +609,7 @@ "status": "active", "date": "2026-04-26", "file_path": "decisions/tooling.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-63", @@ -619,7 +619,7 @@ "status": "active", "date": "2026-04-26", "file_path": "decisions/tooling.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-64", @@ -629,7 +629,7 @@ "status": "active", "date": "2026-05-03", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-65", @@ -639,7 +639,7 @@ "status": "active", "date": "2026-04-26", "file_path": "decisions/tooling.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "D-66", @@ -649,7 +649,7 @@ "status": "active", "date": "2026-05-06", "file_path": "decisions/testing.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-1", @@ -658,7 +658,7 @@ "title": "Authorisation granularity on the IPC socket", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-2", @@ -667,7 +667,7 @@ "title": "Back-pressure on event streams", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-3", @@ -676,7 +676,7 @@ "title": "Event persistence + audit/undo", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-4", @@ -685,7 +685,7 @@ "title": "`.canvas` schema compatibility with Obsidian", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-5", @@ -694,7 +694,7 @@ "title": "IPC wire-format stability + `schema_version:`", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-6", @@ -703,7 +703,7 @@ "title": "Window chrome — native frame vs frameless custom", "status": "resolved", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-7", @@ -712,7 +712,7 @@ "title": "macOS app bundle signing / notarisation", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-8", @@ -721,7 +721,7 @@ "title": "Extension API shape — widgets, subcommands, both?", "status": "open", "file_path": "decisions/questions-extensions.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-9", @@ -730,7 +730,7 @@ "title": "Lua runtime vendoring", "status": "open", "file_path": "decisions/questions-extensions.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-10", @@ -739,7 +739,7 @@ "title": "Extension manifest `schema_version:`", "status": "open", "file_path": "decisions/questions-extensions.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-11", @@ -748,7 +748,7 @@ "title": "Coverage gates — hard thresholds vs soft reporting", "status": "open", "file_path": "decisions/questions-testing.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-12", @@ -757,7 +757,7 @@ "title": "Screen-reader automation (axe-core via Playwright)", "status": "open", "file_path": "decisions/questions-testing.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-13", @@ -766,7 +766,7 @@ "title": "Web production-mode a11y", "status": "open", "file_path": "decisions/questions-accessibility.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-14", @@ -775,7 +775,7 @@ "title": "i18n plurals / gender / date-format tooling", "status": "open", "file_path": "decisions/questions-accessibility.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-15", @@ -784,7 +784,7 @@ "title": "Editor tab — full LSP vs tree-sitter-only highlight", "status": "open", "file_path": "decisions/questions-process.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-16", @@ -793,7 +793,7 @@ "title": "`tree-sitter-dart` grammar maintenance", "status": "open", "file_path": "decisions/questions-process.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-17", @@ -802,7 +802,7 @@ "title": "Icon set growth", "status": "open", "file_path": "decisions/questions-process.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-18", @@ -811,7 +811,7 @@ "title": "Theme hot-reload in release builds", "status": "open", "file_path": "decisions/questions-process.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-19", @@ -820,7 +820,7 @@ "title": "(withdrawn)", "status": "resolved", "file_path": "decisions/questions-process.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-20", @@ -829,7 +829,7 @@ "title": "Kernel DB service — namespaced SQL access?", "status": "open", "file_path": "decisions/questions-process.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-21", @@ -838,7 +838,7 @@ "title": "Pql absorbs planning vs keeps separate", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-22", @@ -847,7 +847,7 @@ "title": "Ticket persistence strategy", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-23", @@ -856,7 +856,7 @@ "title": "SSH-remote development — run clide against a remote workspace", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-25", @@ -865,7 +865,7 @@ "title": "Body text face — mono everywhere vs Josefin Sans UI + mono code", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-26", @@ -874,7 +874,7 @@ "title": "Small screen layout (\u003c 1000px)", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-27", @@ -883,7 +883,7 @@ "title": "Two-editor split", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-28", @@ -892,7 +892,7 @@ "title": "Terminal strip scope — shell only or logs/errors/tests", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-29", @@ -901,7 +901,7 @@ "title": "Branch picker location", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-30", @@ -910,7 +910,7 @@ "title": "Focus behavior when editor is dirty and viewer is peeked", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "Q-31", @@ -919,7 +919,7 @@ "title": "XWayland fallback for frameless — proper Wayland protocol needed", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "R-2", @@ -929,7 +929,7 @@ "status": "active", "date": "2026-04-20", "file_path": "decisions/rejected.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "R-3", @@ -939,7 +939,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "R-4", @@ -949,7 +949,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "R-5", @@ -959,7 +959,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "R-6", @@ -969,7 +969,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "R-7", @@ -979,7 +979,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "R-8", @@ -989,7 +989,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "R-9", @@ -999,7 +999,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "R-10", @@ -1009,7 +1009,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "R-11", @@ -1019,7 +1019,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" }, { "id": "R-12", @@ -1029,7 +1029,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/rejected.md", - "synced_at": "2026-05-07 06:48:06" + "synced_at": "2026-05-07 11:58:58" } ], "decision_refs": [ @@ -1265,7 +1265,7 @@ "source_id": "D-5", "target_id": "D-56", "ref_type": "references", - "note": "**Amendment (2026-04-23):** The separate daemon process and two-package layout are dissolved per [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server). Dart-core and ptyc-as-peer princ..." + "note": "**Amendment (2026-04-23):** The separate daemon process and two-package layout are dissolved per [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server). Dart-core principle survives; th..." }, { "source_id": "D-5", @@ -1848,7 +1848,7 @@ "priority": "medium", "decision_ref": "D-39", "created_at": "2026-04-22 09:41:51", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-2", @@ -1858,7 +1858,7 @@ "priority": "medium", "decision_ref": "D-7", "created_at": "2026-04-22 11:45:26", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-3", @@ -1868,7 +1868,7 @@ "priority": "medium", "decision_ref": "D-41", "created_at": "2026-04-22 11:45:41", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-4", @@ -1878,7 +1878,7 @@ "priority": "medium", "decision_ref": "D-6", "created_at": "2026-04-22 11:45:41", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-5", @@ -1888,7 +1888,7 @@ "priority": "medium", "decision_ref": "D-13", "created_at": "2026-04-22 11:45:41", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-6", @@ -1898,7 +1898,7 @@ "priority": "medium", "decision_ref": "D-3", "created_at": "2026-04-22 11:45:41", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-7", @@ -1909,7 +1909,7 @@ "priority": "medium", "decision_ref": "D-17", "created_at": "2026-04-22 11:45:41", - "updated_at": "2026-05-06 15:08:37" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-8", @@ -1920,7 +1920,7 @@ "priority": "medium", "decision_ref": "D-15", "created_at": "2026-04-22 11:45:41", - "updated_at": "2026-05-06 15:08:53" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-9", @@ -1931,7 +1931,7 @@ "priority": "medium", "decision_ref": "D-34", "created_at": "2026-04-22 11:47:02", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-10", @@ -1942,7 +1942,7 @@ "priority": "medium", "decision_ref": "D-40", "created_at": "2026-04-22 11:47:02", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-11", @@ -1953,7 +1953,7 @@ "priority": "medium", "decision_ref": "D-39", "created_at": "2026-04-22 11:47:02", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-12", @@ -1964,7 +1964,7 @@ "priority": "medium", "decision_ref": "D-42", "created_at": "2026-04-22 11:47:03", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-13", @@ -1975,7 +1975,7 @@ "priority": "medium", "decision_ref": "D-3", "created_at": "2026-04-22 11:47:18", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-14", @@ -1986,7 +1986,7 @@ "priority": "medium", "decision_ref": "D-3", "created_at": "2026-04-22 11:47:18", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-15", @@ -1997,7 +1997,7 @@ "priority": "medium", "decision_ref": "D-3", "created_at": "2026-04-22 11:47:18", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-16", @@ -2008,7 +2008,7 @@ "priority": "medium", "decision_ref": "D-3", "created_at": "2026-04-22 11:47:18", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-17", @@ -2020,7 +2020,7 @@ "priority": "medium", "decision_ref": "D-32", "created_at": "2026-04-22 13:26:29", - "updated_at": "2026-05-06 15:58:30" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-18", @@ -2030,7 +2030,7 @@ "priority": "medium", "decision_ref": "D-5", "created_at": "2026-04-22 13:26:29", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-19", @@ -2041,7 +2041,7 @@ "priority": "medium", "decision_ref": "D-44", "created_at": "2026-04-22 14:08:40", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-20", @@ -2052,7 +2052,7 @@ "priority": "medium", "decision_ref": "D-45", "created_at": "2026-04-22 14:08:40", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-21", @@ -2063,7 +2063,7 @@ "priority": "medium", "decision_ref": "D-43", "created_at": "2026-04-22 14:08:40", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-22", @@ -2074,7 +2074,7 @@ "priority": "medium", "decision_ref": "D-6", "created_at": "2026-04-22 14:08:40", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-23", @@ -2086,7 +2086,7 @@ "priority": "medium", "decision_ref": "D-6", "created_at": "2026-04-22 14:08:40", - "updated_at": "2026-05-06 15:09:05" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-24", @@ -2097,7 +2097,7 @@ "priority": "medium", "decision_ref": "D-41", "created_at": "2026-04-22 14:08:40", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-25", @@ -2108,7 +2108,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-22 20:14:24", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-26", @@ -2119,7 +2119,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-22 20:14:33", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-27", @@ -2130,7 +2130,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-22 20:14:45", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-28", @@ -2141,7 +2141,7 @@ "status": "done", "priority": "medium", "created_at": "2026-04-22 20:14:52", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-29", @@ -2152,7 +2152,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-22 20:17:26", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-30", @@ -2163,7 +2163,7 @@ "priority": "high", "decision_ref": "D-51", "created_at": "2026-04-22 20:33:53", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-31", @@ -2174,7 +2174,7 @@ "priority": "medium", "decision_ref": "D-52", "created_at": "2026-04-22 20:33:56", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-32", @@ -2185,7 +2185,7 @@ "priority": "medium", "decision_ref": "D-53", "created_at": "2026-04-22 20:33:58", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-33", @@ -2196,7 +2196,7 @@ "priority": "high", "decision_ref": "D-54", "created_at": "2026-04-22 20:34:01", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-34", @@ -2207,7 +2207,7 @@ "priority": "medium", "decision_ref": "D-47", "created_at": "2026-04-22 20:34:03", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-35", @@ -2219,7 +2219,7 @@ "priority": "medium", "decision_ref": "D-49", "created_at": "2026-04-22 20:34:06", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-36", @@ -2231,7 +2231,7 @@ "priority": "low", "decision_ref": "D-50", "created_at": "2026-04-22 20:34:09", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-37", @@ -2243,7 +2243,7 @@ "priority": "medium", "decision_ref": "D-47", "created_at": "2026-04-22 21:03:40", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-38", @@ -2255,7 +2255,7 @@ "priority": "medium", "decision_ref": "D-47", "created_at": "2026-04-22 21:03:40", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-39", @@ -2267,7 +2267,7 @@ "priority": "medium", "decision_ref": "D-47", "created_at": "2026-04-22 21:03:40", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-40", @@ -2279,7 +2279,7 @@ "priority": "low", "decision_ref": "D-47", "created_at": "2026-04-22 21:03:40", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-41", @@ -2291,7 +2291,7 @@ "priority": "low", "decision_ref": "D-47", "created_at": "2026-04-22 21:03:40", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-42", @@ -2303,7 +2303,7 @@ "priority": "low", "decision_ref": "D-48", "created_at": "2026-04-22 21:03:40", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-43", @@ -2315,7 +2315,7 @@ "priority": "high", "decision_ref": "D-41", "created_at": "2026-04-22 22:01:13", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-44", @@ -2325,7 +2325,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:25:26", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-45", @@ -2336,7 +2336,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:27:28", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-46", @@ -2346,7 +2346,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:27:58", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-47", @@ -2357,7 +2357,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:28:43", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-48", @@ -2367,7 +2367,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:30:09", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-49", @@ -2377,7 +2377,7 @@ "status": "done", "priority": "medium", "created_at": "2026-04-23 20:31:35", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-50", @@ -2387,7 +2387,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:31:48", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-51", @@ -2397,7 +2397,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:32:06", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-52", @@ -2407,7 +2407,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:32:06", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-53", @@ -2417,7 +2417,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:32:06", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-54", @@ -2427,7 +2427,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:32:06", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-55", @@ -2437,7 +2437,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:32:06", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-56", @@ -2447,7 +2447,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:32:06", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-57", @@ -2457,7 +2457,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:32:06", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-58", @@ -2467,7 +2467,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:32:06", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-59", @@ -2477,7 +2477,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:32:06", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-60", @@ -2487,7 +2487,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:32:06", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-61", @@ -2497,7 +2497,7 @@ "status": "done", "priority": "medium", "created_at": "2026-04-24 06:34:16", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-62", @@ -2507,7 +2507,7 @@ "status": "done", "priority": "medium", "created_at": "2026-04-24 06:34:16", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-63", @@ -2517,7 +2517,7 @@ "status": "done", "priority": "medium", "created_at": "2026-04-24 06:34:16", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-64", @@ -2527,7 +2527,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-24 06:34:16", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-65", @@ -2537,7 +2537,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-24 06:34:16", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-66", @@ -2547,7 +2547,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-24 06:34:16", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-67", @@ -2557,7 +2557,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-24 06:34:16", - "updated_at": "2026-05-06 15:06:40" + "updated_at": "2026-05-07 07:40:13" }, { "id": "T-68", diff --git a/CHANGELOG.md b/CHANGELOG.md index 69bbc2d6..42ad04ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,26 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ## [Unreleased] +### Removed + +- **`bin/clide.dart` + `DaemonServer`** — completing the D-56 dissolution. + The separate daemon process was dissolved on 2026-04-23 but the entry + point and socket server class were never actually deleted. Gone now, + along with orphaned tests (`test/cli/`, `test/daemon/subprocess_test`, + `test/daemon/in_process_test`), stale i18n strings, and "start + `clide --daemon`" error messages. +- **`ptyc/` source tree + `PtySession` + `scm_rights.dart`** — PTY + spawning migrated to Dart FFI `forkpty()` (`NativePty`) but the old + C helper and its Dart wiring were never cleaned up. Removed from + toolchain resolution, `ToolCheck` gate, backend serialization, + testmode harness, CI scripts, Makefile, and sandbox entitlements. + D-5 amended to record the retirement. +- CI golden images (`test/goldens/goldens/ci/`) — Skia anti-aliasing + of geometric shapes differs between macOS and Linux even with the + Ahem font, so a single set of CI goldens can't serve both platforms. + Replaced with platform-keyed goldens (`goldens/linux/`, + `goldens/macos/`), each only compared on its own OS. + ### Added - Pre-push coverage gate — `make push-check` (and the diff --git a/CLAUDE.md b/CLAUDE.md index d649b5fe..6a47a40e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,11 +4,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What clide is -An IDE for Claude Code CLI. Single Flutter package at the repo root, plus small native supporter tools where Dart can't reach. +An IDE for Claude Code CLI. Single Flutter package at the repo root. -- **`lib/`** — all Dart code. Subsystem handlers (`lib/src/daemon/`, `lib/src/pty/`, `lib/src/ipc/`, `lib/src/git/`, `lib/src/pql/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), and the extension framework (`lib/extension/`). The Flutter app hosts the IPC server in-process (D-56). +- **`lib/`** — all Dart code. Subsystem handlers (`lib/src/daemon/`, `lib/src/pty/`, `lib/src/ipc/`, `lib/src/git/`, `lib/src/pql/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), and the extension framework (`lib/extension/`). The Flutter app hosts the IPC server in-process (D-56). PTY spawning uses Dart FFI `forkpty()` directly. - **[`pql`](https://github.com/postmeridiem/pql)** — external supporter tool. Clide wraps it for every query surface; never re-implements it. -- **`ptyc/`** — small C supporter tool, peer of pql. Spawns a PTY + child and hands the master fd back over `SCM_RIGHTS`. Clide shells out to it for every pane (shell, tmux, claude, LSP, debug adapter). tmux owns Claude session persistence (D-41) — the app re-attaches on restart via `tmux new-session -A`. Native rendering — markdown, canvas, graph — is Dart/Flutter (`CustomPaint` + widgets), not third-party packages. @@ -19,10 +18,10 @@ Design doc: [`docs/initial-plan.md`](docs/initial-plan.md). Decisions: [`decisio These are load-bearing. Violating any means the design is wrong, not the rule. - **Flutter desktop is the host. No Electron, ever.** Web target may work as a happy accident — don't compromise desktop fidelity for it. If we ship a web build at all, prefer Flutter's **WebAssembly (CanvasKit/Skwasm) compile** over the JS/HTML renderer. `xterm.dart` is the terminal renderer; markdown, canvas, graph are custom `CustomPaint`/widget components. -- **Single process.** The Flutter app hosts everything in-process: IPC server, subsystem handlers (pane, files, editor, git, pql), extensions. No separate daemon binary (D-56 dissolved it). The CLI surface for Claude is a thin C client (ptyc peer). +- **Single process.** The Flutter app hosts everything in-process: IPC server, subsystem handlers (pane, files, editor, git, pql), extensions. No separate daemon binary (D-56 dissolved it). - **CLI-first, not MCP.** Claude talks via Bash (`clide ...`), matching pql's contract. See [`D-1`](decisions/architecture.md#d-1-cli-first-not-mcp). -- **Dart is the core; native supporter tools fill specific gaps.** `ptyc` (C) for PTY spawning. `pql` (Go) for queries. No second "core language." See [`D-5`](decisions/architecture.md#d-5-dart-core-sidecar-dissolved-ptyc-as-pql-peer) (amended by D-56). -- **Own the rendering stack.** PTY (via `ptyc`), markdown renderer, graph, canvas — all clide-owned, not pulled from opinionated packages. +- **Dart is the core; pql fills the query gap.** PTY spawning is native Dart FFI (`forkpty`). `pql` (Go) handles vault queries. No second "core language." See [`D-5`](decisions/architecture.md#d-5-dart-core-sidecar-dissolved-ptyc-as-pql-peer) (amended by D-56). +- **Own the rendering stack.** PTY (via Dart FFI), markdown renderer, graph, canvas — all clide-owned, not pulled from opinionated packages. - **User/Claude parity.** Every CLI subcommand has a UI affordance, and every UI action has a CLI. See [`D-6`](decisions/architecture.md#d-6-cli-and-event-surface-contract). - **pql: wrap, don't duplicate.** Pql logic only lives in `lib/src/pql/` (pure shell-outs). Clide owns pql's `ignore_files:` config key; it never touches pql's `.pql/` index/cache data. See [`D-3`](decisions/architecture.md#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates). - **Repo-is-the-workspace.** The git repo root is the workspace — no parallel "vault" concept. @@ -46,8 +45,7 @@ lib/ test/ # All tests (core subsystems + widgets + goldens + a11y) assets/ # Fonts, themes, grammars, licenses, logo linux/, macos/, web/ # Flutter platform directories -ptyc/ # C PTY helper -native/ # Vendored native libs (libtree-sitter.so) +native/ # Vendored native libs (libtree-sitter.so, dugite) decisions/ # D/Q/R records docs/ # Design docs, wireframes legacy/ # Python Textual clide v1.2 (frozen) @@ -57,7 +55,7 @@ legacy/ # Python Textual clide v1.2 (frozen) - **Prefer-zero-deps.** Flutter-SDK widgets first; third-party packages need justification. What stays is exact-pinned in `pubspec.yaml` (no caret ranges). Advisories reviewed before every bump; `pubspec.lock` committed. - **Document every bundled dependency.** Listed in [`assets/licenses.yaml`](assets/licenses.yaml) with name, kind, version, homepage, license, and purpose. Adding a dep is a two-step commit: add the artefact **and** the `licenses.yaml` entry. See [`D-42`](decisions/tooling.md#d-42-bundled-dependencies-documented-in-licensesyaml). -- **`ptyc` and any future native supporter tool:** no dep graph by design (libc-only for `ptyc`). "Audit" is reading the source before each bump. +- **Native deps (dugite, libtree-sitter):** vendored in `native/`, pinned by SHA. Bumps follow the same advisory-review + `licenses.yaml` rule. ## Commands @@ -71,7 +69,6 @@ make test-a11y # accessibility contract tests make test-integration# real app boot integration tests make build-linux # flutter build linux make build-macos # flutter build macos -make ptyc-build # build the ptyc PTY-spawn helper make push-check # pre-push gate: decisions + core + fast tests + a11y make hooks # install the repo's git hooks (one-time setup) make clean # remove build artefacts diff --git a/Makefile b/Makefile index e8224465..a3960be9 100644 --- a/Makefile +++ b/Makefile @@ -234,40 +234,11 @@ dugite-fetch: ## Download and extract the dugite-native git distribution. dugite-clean: ## Remove the dugite-native directory. rm -rf $(DUGITE_DIR) -# -- ptyc (C supporter tool) --------------------------------------------- - -PTYC_PRESENT := $(shell test -f ptyc/Makefile && echo yes || echo no) - -.PHONY: ptyc-build -ptyc-build: ## Build the ptyc PTY-spawn helper. -ifeq ($(PTYC_PRESENT),yes) - $(MAKE) -C ptyc -else - @echo "(ptyc/ not scaffolded yet; skipping)" -endif - -.PHONY: ptyc-test -ptyc-test: ## Run ptyc smoke tests (SCM_RIGHTS round-trip). -ifeq ($(PTYC_PRESENT),yes) - $(MAKE) -C ptyc test -else - @echo "(ptyc/ not scaffolded yet; skipping)" -endif - -.PHONY: ptyc-clean -ptyc-clean: ## Clean ptyc build artefacts. -ifeq ($(PTYC_PRESENT),yes) - $(MAKE) -C ptyc clean -else - @echo "(ptyc/ not scaffolded yet; skipping)" -endif - # -- security ------------------------------------------------------------- .PHONY: security -security: ## Dart advisory review + ptyc source review. - @echo "security: Dart advisories reviewed manually before pubspec.yaml bumps;" - @echo " ptyc is reviewed by reading it (tiny libc-only C)." +security: ## Dart advisory review. + @echo "security: Dart advisories reviewed manually before pubspec.yaml bumps." # -- pre-push gate -------------------------------------------------------- @@ -288,6 +259,5 @@ hooks: ## Install the repo's git hooks. .PHONY: clean clean: ## Remove build artefacts. rm -rf build .dart_tool - $(MAKE) ptyc-clean .DEFAULT_GOAL := help diff --git a/bin/clide.dart b/bin/clide.dart deleted file mode 100644 index 96933f84..00000000 --- a/bin/clide.dart +++ /dev/null @@ -1,434 +0,0 @@ -// clide — CLI + daemon entry point. -// -// One binary, two modes (per D-005): -// * `clide ` — one-shot; connects to the daemon socket, -// sends a request, prints the response, exits with the D-006 -// exit code. -// * `clide --daemon` — long-running; owns the socket, dispatches -// requests. Subsystems (pane, files, editor, …) register handlers -// at boot. - -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:clide/clide.dart'; -import 'package:clide/kernel/src/toolchain.dart'; -// Daemon-only deep imports — these pull in dart:ffi (PTY) and -// daemon-subsystem wiring that the Flutter app doesn't need and -// can't compile for web. See lib/clide.dart for the barrel split. -import 'package:clide/src/daemon/editor_commands.dart'; -import 'package:clide/src/daemon/files_commands.dart'; -import 'package:clide/src/daemon/git_commands.dart'; -import 'package:clide/src/daemon/pane_commands.dart'; -import 'package:clide/src/daemon/pql_commands.dart'; -import 'package:clide/src/editor/registry.dart' show EditorRegistry; -import 'package:clide/src/panes/registry.dart'; - -Future main(List argv) async { - if (argv.isEmpty) { - _printHelp(stdout); - exit(0); - } - - if (argv.first == '--daemon') { - await _runDaemon(argv.sublist(1)); - return; - } - - // Tier-2 single-word shortcuts (per CLAUDE.md). Each maps a flat - // positional argv into the structured IPC shape of the canonical - // editor.* / pane.* verb. Keeps Claude's tool-use pattern short. - final rest = argv.sublist(1); - switch (argv.first) { - case '--version': - case 'version': - await _runCliArgs('version', const {}, exitOnOk: true); - case '--help': - case '-h': - case 'help': - _printHelp(stdout); - exit(0); - case 'ping': - await _runCliArgs('ping', const {}, exitOnOk: true); - case 'open': - if (rest.isEmpty) _die('usage: clide open '); - await _runCliArgs('editor.open', {'path': rest.first}, exitOnOk: true); - case 'active': - await _runCliArgs('editor.active', const {}, exitOnOk: true); - case 'insert': - final text = await _readTextArg(rest); - await _runCliArgs('editor.insert', {'text': text}, exitOnOk: true); - case 'replace-selection': - final text = await _readTextArg(rest); - await _runCliArgs( - 'editor.replace-selection', - {'text': text}, - exitOnOk: true, - ); - case 'save': - await _runCliArgs('editor.save', const {}, exitOnOk: true); - case 'git': - await _runGit(rest); - case 'tail': - await _runTail(rest); - default: - // Unknown-to-the-CLI commands still go over IPC — the daemon is - // authoritative about what's registered. Lets extensions add - // subcommands without the CLI caring. Args forward as-is under - // {argv: [...]} so daemon-side can parse whatever shape it wants. - await _runCliArgs( - argv.first, - {'argv': rest}, - exitOnOk: true, - ); - } -} - -void _printHelp(IOSink sink) { - sink.writeln(''' -clide $clideVersion — IDE for Claude Code CLI. - -Usage: - clide --daemon Run the long-running daemon process. - clide Run a one-shot subcommand against the daemon. - -Built-in subcommands: - ping Round-trip a ping to the daemon. - version Print the clide version. - help Print this help. - -Editor (tier 2): - open Open a file in the editor (editor.open). - active Print the active buffer (editor.active). - insert Insert text at the cursor in the active buffer. - `-` reads text from stdin. - replace-selection <…> Replace the selected text in the active buffer. - `-` reads text from stdin. - save Save the active buffer (editor.save). - -Git (tier 3): - git status Working tree status (staged/unstaged/conflicts). - git diff [--staged] [P] Diff for file(s) P, or all if omitted. - git stage Stage files (git add). - git stage-all Stage everything (git add -A). - git unstage [paths…] Unstage files (git reset HEAD). - git discard Discard unstaged changes in files. - git commit "" Commit staged changes. - git log [--count N] Recent commit log (default 20). - git stash [--message M] Stash working changes. - git stash-pop Pop the top stash entry. - git pull Pull from remote. - git push [remote] [br] Push to remote. - -Event subscription: - tail --events [--filter SUBSYSTEM[:ID]] - Stream events as JSON lines. --filter keeps - only events from one subsystem, optionally - narrowed to a single id. Exits on SIGINT. - -Any other subcommand is forwarded to the daemon; registered handlers -(e.g. `clide git status` once `builtin.git` lands) resolve there. -Matches D-006's exit-code contract: - 0 success · 1 user-error · 2 tool-error · 3 not-found · 4 conflict -'''); -} - -Future _runDaemon(List args) async { - final socketPath = defaultSocketPath(); - final dispatcher = DaemonDispatcher(); - late final DaemonServer server; - server = DaemonServer( - socketPath: socketPath, - dispatch: dispatcher.dispatch, - ); - final toolchain = Toolchain(); - toolchain.applyResolved(Toolchain.resolvePaths(workspaceRoot: Directory.current.path)); - - final events = _ServerEventSink(server); - final registry = PaneRegistry(events: events); - registerPaneCommands(dispatcher, registry); - - final files = FilesService.atCwd(events: events); - registerFilesCommands(dispatcher, files); - - final editor = EditorRegistry(events: events, workspaceRoot: files.root); - registerEditorCommands(dispatcher, editor); - final gitClient = GitClient(toolchain: toolchain, workDir: files.root); - registerGitCommands(dispatcher, gitClient, events); - - final pql = PqlClient(workDir: files.root, toolchain: toolchain); - registerPqlCommands(dispatcher, pql); - - final stopping = Completer(); - void shutdown(ProcessSignal sig) { - if (!stopping.isCompleted) { - stderr.writeln('clide daemon: received ${sig.toString()}, shutting down'); - stopping.complete(); - } - } - - ProcessSignal.sigint.watch().listen(shutdown); - ProcessSignal.sigterm.watch().listen(shutdown); - - await server.start(); - await stopping.future; - await registry.shutdown(); - await editor.shutdown(); - await files.shutdown(); - await server.stop(); - exit(0); -} - -/// Thin adapter: the server doesn't `implement DaemonEventSink` itself -/// (that would tie ipc/ to panes/); instead the daemon entrypoint wraps -/// it at the seam where both are known. -class _ServerEventSink implements DaemonEventSink { - _ServerEventSink(this._server); - final DaemonServer _server; - - @override - void emit(IpcEvent event) => _server.broadcast(event); -} - -// --------------------------------------------------------------------------- -// CLI helpers -// --------------------------------------------------------------------------- - -/// Read the "text" argument for insert / replace-selection. A lone -/// `-` means "slurp stdin"; anything else is concatenated into the -/// text body (so `clide insert hello world` emits "hello world"). -Future _readTextArg(List rest) async { - if (rest.isEmpty) _die('usage: clide (or `-` to read stdin)'); - if (rest.length == 1 && rest.first == '-') { - final bytes = []; - await for (final chunk in stdin) { - bytes.addAll(chunk); - } - return utf8.decode(bytes); - } - return rest.join(' '); -} - -Future _connectSocket() async { - final socketPath = defaultSocketPath(); - try { - return await Socket.connect( - InternetAddress(socketPath, type: InternetAddressType.unix), - 0, - ); - } catch (_) { - _emitError( - code: IpcExitCode.toolError, - kind: IpcErrorKind.toolError, - message: 'daemon not reachable at $socketPath', - hint: 'run `clide --daemon` in another terminal.', - ); - exit(IpcExitCode.toolError); - } -} - -Future _runCliArgs( - String cmd, - Map args, { - required bool exitOnOk, -}) async { - final socket = await _connectSocket(); - final request = IpcRequest(id: '1', cmd: cmd, args: args); - socket.writeln(request.encode()); - - // Responses come back on the same socket. Events may be interleaved - // (the daemon broadcasts), so we skip events until we see the - // response whose id matches our request. - final lines = socket.cast>().transform(utf8.decoder).transform(const LineSplitter()); - - try { - await for (final line in lines) { - if (line.isEmpty) continue; - final msg = IpcMessage.decode(line); - if (msg is! IpcResponse) continue; - if (msg.id != request.id) continue; - await socket.close(); - if (msg.ok) { - stdout.writeln(jsonEncode(msg.data)); - if (exitOnOk) exit(IpcExitCode.ok); - return; - } else { - final err = msg.error!; - _emitError( - code: err.code, - kind: err.kind, - message: err.message, - hint: err.hint, - ); - exit(err.code); - } - } - _emitError( - code: IpcExitCode.toolError, - kind: IpcErrorKind.toolError, - message: 'daemon closed socket before responding', - ); - exit(IpcExitCode.toolError); - } on FormatException catch (e) { - _emitError( - code: IpcExitCode.toolError, - kind: IpcErrorKind.toolError, - message: 'bad response from daemon: $e', - ); - exit(IpcExitCode.toolError); - } -} - -/// `clide git [args]` — maps to `git.*` IPC verbs. -Future _runGit(List args) async { - if (args.isEmpty) { - _die('usage: clide git [args…]'); - } - switch (args.first) { - case 'status': - await _runCliArgs('git.status', const {}, exitOnOk: true); - case 'diff': - final staged = args.contains('--staged'); - final paths = args.sublist(1).where((a) => !a.startsWith('--')).toList(); - await _runCliArgs( - 'git.diff', - {'staged': staged, if (paths.isNotEmpty) 'paths': paths}, - exitOnOk: true, - ); - case 'stage': - final paths = args.sublist(1); - if (paths.isEmpty) _die('usage: clide git stage '); - await _runCliArgs('git.stage', {'paths': paths}, exitOnOk: true); - case 'stage-all': - await _runCliArgs('git.stage-all', const {}, exitOnOk: true); - case 'unstage': - final paths = args.sublist(1); - await _runCliArgs('git.unstage', {'paths': paths}, exitOnOk: true); - case 'discard': - final paths = args.sublist(1); - if (paths.isEmpty) _die('usage: clide git discard '); - await _runCliArgs('git.discard', {'paths': paths}, exitOnOk: true); - case 'commit': - if (args.length < 2) _die('usage: clide git commit ""'); - final message = args.sublist(1).join(' '); - await _runCliArgs('git.commit', {'message': message}, exitOnOk: true); - case 'log': - var count = 20; - for (var i = 1; i < args.length; i++) { - if (args[i] == '--count' && i + 1 < args.length) { - count = int.tryParse(args[i + 1]) ?? 20; - } - } - await _runCliArgs('git.log', {'count': count}, exitOnOk: true); - case 'stash': - String? message; - for (var i = 1; i < args.length; i++) { - if (args[i] == '--message' && i + 1 < args.length) { - message = args[i + 1]; - } - } - await _runCliArgs( - 'git.stash', - {if (message != null) 'message': message}, - exitOnOk: true, - ); - case 'stash-pop': - await _runCliArgs('git.stash-pop', const {}, exitOnOk: true); - case 'pull': - await _runCliArgs('git.pull', const {}, exitOnOk: true); - case 'push': - final rest = args.sublist(1); - final setUpstream = rest.contains('-u'); - final positional = rest.where((a) => a != '-u').toList(); - await _runCliArgs( - 'git.push', - { - if (setUpstream) 'setUpstream': true, - if (positional.isNotEmpty) 'remote': positional[0], - if (positional.length > 1) 'branch': positional[1], - }, - exitOnOk: true); - default: - _die('unknown git verb: ${args.first}'); - } -} - -/// `clide tail --events [--filter SUBSYSTEM[:ID]]` — stream events. -Future _runTail(List args) async { - // Parse flags: --events (required today; keeps us honest when more - // modes like --history land), --filter SUBSYSTEM[:ID]. - var wantEvents = false; - String? filterSubsystem; - String? filterId; - for (var i = 0; i < args.length; i++) { - final a = args[i]; - if (a == '--events') { - wantEvents = true; - } else if (a == '--filter') { - if (i + 1 >= args.length) _die('--filter requires an argument'); - final spec = args[++i]; - final colon = spec.indexOf(':'); - if (colon < 0) { - filterSubsystem = spec; - } else { - filterSubsystem = spec.substring(0, colon); - filterId = spec.substring(colon + 1); - } - } else { - _die('unknown argument: $a'); - } - } - if (!wantEvents) _die('clide tail: pass --events'); - - final socket = await _connectSocket(); - - // Shutdown on SIGINT / SIGTERM — close the socket so the stream - // drains and we exit cleanly. - void quit() { - unawaited(socket.close()); - } - - ProcessSignal.sigint.watch().listen((_) => quit()); - ProcessSignal.sigterm.watch().listen((_) => quit()); - - final lines = socket.cast>().transform(utf8.decoder).transform(const LineSplitter()); - - try { - await for (final line in lines) { - if (line.isEmpty) continue; - IpcMessage msg; - try { - msg = IpcMessage.decode(line); - } on FormatException { - continue; - } - if (msg is! IpcEvent) continue; - if (filterSubsystem != null && msg.subsystem != filterSubsystem) continue; - if (filterId != null && msg.data['id'] != filterId) continue; - stdout.writeln(line); - } - } finally { - await socket.close(); - } - exit(0); -} - -void _emitError({ - required int code, - required String kind, - required String message, - String? hint, -}) { - final err = IpcError(code: code, kind: kind, message: message, hint: hint); - stderr.writeln(jsonEncode(err.toJson())); -} - -Never _die(String msg) { - _emitError( - code: IpcExitCode.userError, - kind: IpcErrorKind.userError, - message: msg, - ); - exit(IpcExitCode.userError); -} diff --git a/ci/test_core.sh b/ci/test_core.sh index 2e36f3d1..8064c149 100755 --- a/ci/test_core.sh +++ b/ci/test_core.sh @@ -20,11 +20,6 @@ if ! command -v dart >/dev/null; then exit 2 fi -if ! command -v ptyc >/dev/null && [[ ! -x "ptyc/bin/ptyc" ]]; then - echo "test-core: building ptyc (required by PTY tests)" - make -C ptyc >/dev/null -fi - # Hard timeout (seconds). The PTY tests should finish in <5s; IPC/daemon # tests are faster still. 120s is generous for CI warmup, tiny for a # hang. @@ -33,7 +28,7 @@ TIMEOUT_SECONDS=${TIMEOUT_SECONDS:-120} # Run dart test in its own process group so we can kill descendants on # timeout. `setsid` starts a new session; `timeout --kill-after` SIGKILLs # after SIGTERM if the test ignores it. -CORE_DIRS="test/ipc test/pty test/daemon test/git test/panes test/files test/editor test/cli test/pql" +CORE_DIRS="test/ipc test/pty test/daemon test/git test/panes test/files test/editor test/pql" echo "test-core: dart test ${CORE_DIRS} (timeout ${TIMEOUT_SECONDS}s)" if ! timeout --kill-after=5s "${TIMEOUT_SECONDS}s" \ @@ -42,7 +37,6 @@ if ! timeout --kill-after=5s "${TIMEOUT_SECONDS}s" \ if [[ $rc -eq 124 ]]; then echo "test-core: TIMEOUT — killing descendants" >&2 pkill -9 -f "dart test test/" 2>/dev/null || true - pkill -9 -f "ptyc" 2>/dev/null || true exit 1 fi exit $rc diff --git a/ci/test_e2e.sh b/ci/test_e2e.sh index ed92c5f8..f36ad4a6 100755 --- a/ci/test_e2e.sh +++ b/ci/test_e2e.sh @@ -1,16 +1,8 @@ #!/usr/bin/env bash -# End-to-end layer: daemon subprocess + web WASM Playwright smoke. -# Neither fits in `make test`; together they're the "everything still -# works across process/runtime boundaries" gate. +# End-to-end layer: web WASM Playwright smoke. set -euo pipefail cd "$(dirname "$0")/.." -echo "==> build bin/clide (required by daemon subprocess test)" -make build - -echo "==> daemon subprocess test" -dart test test/daemon/ - echo "==> browser WASM smoke (Playwright)" ./tools/ui/build.sh ./tools/ui/serve.sh diff --git a/decisions/architecture.md b/decisions/architecture.md index 86447a93..2a72af4e 100644 --- a/decisions/architecture.md +++ b/decisions/architecture.md @@ -97,7 +97,8 @@ Core, rendering, IPC, kernel, panel manager. ### D-5: Dart core; sidecar dissolved; `ptyc` as pql-peer - **Date:** 2026-04-20 (was ADR 0005; supersedes [R-2](rejected.md#r-2-go-sidecar)) -- **Amendment (2026-04-23):** The separate daemon process and two-package layout are dissolved per [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server). Dart-core and ptyc-as-peer principles survive; the daemon binary does not. +- **Amendment (2026-04-23):** The separate daemon process and two-package layout are dissolved per [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server). Dart-core principle survives; the daemon binary does not. +- **Amendment (2026-05-07):** `ptyc` retired. PTY spawning moved to Dart FFI `forkpty()` (`lib/src/pty/native_pty.dart`). The `ptyc/` source tree, `PtySession`, and `scm_rights.dart` are removed. `pql` remains the sole external supporter tool. - **Decision:** Three moves. **(1) Dart is the core language.** Everything that used to live under `sidecar/` — IPC server, CLI dispatch, process management, file watching, git shell-outs, pql wrapper — is written in Dart. Two execution modes of one Dart AOT binary: `clide ` (one-shot, pql-style) and `clide --daemon` (long-running, owns PTYs and subprocesses, survives app restarts). The Flutter app imports the Dart core as a library *and* connects to the daemon over IPC. **(2) The sidecar directory dissolves.** Layout is `app/` (Flutter UI), `lib/` (Dart core), `bin/clide.dart` (AOT entry), `ptyc/` (C helper), no `sidecar/`, no Go module. **(3) `ptyc` is a pql-peer supporter tool.** Small C binary that does `posix_openpt` + `fork` + `exec` + fd-passing via `SCM_RIGHTS`; clide wraps it the same way it wraps pql. Shells out for every PTY (terminal pane, tmux session, Claude, LSP server, debug adapter — one code path). Consumers other than clide can use `ptyc` standalone. - **Context:** [R-2](rejected.md#r-2-go-sidecar) picked Go for the sidecar/CLI on two premises: (a) the heavy work belongs in a language separate from the UI layer, and (b) pql is Go so the muscle memory transfers. On reassessment, both premises broke: the "heavy work" is I/O-bound glue that `dart:io` covers cleanly — the real choice was **separate process vs shared language**, and separate-process is what matters. PTY is the one place Dart is genuinely weak (multi-threaded VM can't safely `fork()`), and once you accept a small native helper, *nothing else* needs to be in the same language. - **Rationale:** One toolchain for the IDE proper (Flutter + Dart). C toolchain needed only to build `ptyc` — tiny, rarely-changing. Session persistence stays because PTY master fds live in the Dart daemon process, not the app. `ptyc` naming: **p** for *project* (parallel to pql's *project query language*), **ptyc** reads as both "PTY + child" (domain vocabulary) and "PTY + C" (implementation language). Usable from Dart, Python, Go, shell — anywhere a subprocess can be spawned and a fd received. diff --git a/decisions/testing.md b/decisions/testing.md index 8d3ee961..8484a94e 100644 --- a/decisions/testing.md +++ b/decisions/testing.md @@ -6,7 +6,7 @@ Test pyramid, drivers, client-side constraint. ### D-23: Test pyramid — seven layers - **Date:** 2026-04-21 -- **Decision:** The pyramid has seven layers: unit (pure Dart) → widget (pumped + find) → golden (visual primitives) → a11y (semantics coverage + keyboard + contrast + i18n) → integration (`flutter test integration_test/`) → E2E (Playwright driving the WASM build + `clide --daemon` subprocess) → startup-smoke (`ci/smoke_bundle.sh`: build Linux release, run under xvfb for 5 s). +- **Decision:** The pyramid has seven layers: unit (pure Dart) → widget (pumped + find) → golden (visual primitives) → a11y (semantics coverage + keyboard + contrast + i18n) → integration (`flutter test integration_test/`) → E2E (Playwright driving the WASM build) → startup-smoke (`ci/smoke_bundle.sh`: build Linux release, run under xvfb for 5 s). - **Rationale:** Each layer catches a distinct regression class. Skipping any layer means that class ships unprotected. Pushed back when earlier rounds proposed "just widget + E2E"; widget can't catch paint regressions (that's golden), E2E can't catch a11y tree drift (that's semantics). - **Cost:** Seven CI jobs; total wall time budgeted at < 15 min. Pre-push runs layers 1-4 (< 90 s — see [D-29](#d-29-pre-push-gate-fast-layer-only)). - **Raised by:** 2026-04-21 planning. diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index c9f61f44..a9f34deb 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -64,7 +64,7 @@ class _ClaudePaneState extends State { // Primary panes leave the tmux session alive so the next launch // re-attaches via `tmux new-session -A` (D-41). // - // pane.close kills the ptyc-spawned tmux *client*; the tmux server + // pane.close kills the PTY-spawned tmux *client*; the tmux server // keeps the session alive. We need an explicit kill-session for // secondaries to actually disappear (D-41 close semantics). if (id != null && !widget.isPrimary) { diff --git a/lib/builtin/claude/src/tmux_session.dart b/lib/builtin/claude/src/tmux_session.dart index c8cccf19..96ac8f67 100644 --- a/lib/builtin/claude/src/tmux_session.dart +++ b/lib/builtin/claude/src/tmux_session.dart @@ -1,6 +1,6 @@ /// tmux server interactions for Claude panes (D-41 lifecycle). /// -/// `pane.close` only kills the ptyc-spawned tmux *client*; tmux is +/// `pane.close` only kills the PTY-spawned tmux *client*; tmux is /// client/server, so the server-side session keeps running after the /// client disconnects. To honour D-41 ("closing a secondary kills that /// tmux session" + "secondary numbering resets between clide runs"), diff --git a/lib/builtin/terminal/src/terminal_pane.dart b/lib/builtin/terminal/src/terminal_pane.dart index 6b24699d..d3bcf29b 100644 --- a/lib/builtin/terminal/src/terminal_pane.dart +++ b/lib/builtin/terminal/src/terminal_pane.dart @@ -62,7 +62,7 @@ class _TerminalPaneState extends State { if (!mounted) return; final ipc = _kernelIpc(); if (ipc == null || !ipc.isConnected) { - setState(() => _error = 'Daemon not connected. Start `clide --daemon`.'); + setState(() => _error = 'Backend not connected.'); return; } diff --git a/lib/clide.dart b/lib/clide.dart index 1e352d83..5f6fe99a 100644 --- a/lib/clide.dart +++ b/lib/clide.dart @@ -1,8 +1,5 @@ /// clide — Dart core library. /// -/// Shared by `bin/clide.dart` (CLI + daemon) and by the Flutter app -/// under `app/` (which depends on this package via `path: ../`). -/// /// See: /// * `decisions/architecture.md` `D-005` — layout + language rationale. /// * `decisions/architecture.md` `D-006` — CLI + event contract. @@ -10,11 +7,10 @@ library; // Flutter-app-visible surface. Deliberately **does not** export the // `pty/` or `panes/registry.dart` modules — those import `dart:ffi` -// and pull in the PTY machinery that only runs on desktop. The -// daemon entrypoint (`bin/clide.dart`) imports them via deep paths. +// and pull in the PTY machinery that only runs on desktop. // // `Pane` + `PaneKind` + the event-sink interfaces travel here because -// they're pure data types that both the app and the daemon reference. +// they're pure data types referenced throughout the app. export 'src/daemon/dispatcher.dart'; export 'src/editor/buffer.dart'; @@ -28,15 +24,14 @@ export 'src/pql/client.dart' show PqlClient, PqlException; export 'src/ipc/envelope.dart'; export 'src/ipc/paths.dart'; export 'src/ipc/schema_v1.dart'; -export 'src/ipc/server.dart'; export 'src/panes/event_sink.dart'; export 'src/panes/pane.dart' show Pane, PaneKind; /// Build-time-stamped version string. /// -/// The Makefile's `build` target passes `--define=clideVersion=…` when -/// invoking `dart compile exe`, stamping `project.yaml`'s `version:` -/// plus the git short SHA and dirty marker. +/// The Makefile's `build` target passes `--define=clideVersion=…`, +/// stamping `pubspec.yaml`'s `version:` plus the git short SHA and +/// dirty marker. const clideVersion = String.fromEnvironment( 'clideVersion', defaultValue: '2.0.0-dev', diff --git a/lib/kernel/src/backend.dart b/lib/kernel/src/backend.dart index 6d040ffe..be242d55 100644 --- a/lib/kernel/src/backend.dart +++ b/lib/kernel/src/backend.dart @@ -60,7 +60,6 @@ class Backend { git: tcData['git'] as String?, pql: tcData['pql'] as String?, tmux: tcData['tmux'] as String?, - ptyc: tcData['ptyc'] as String?, shell: tcData['shell'] as String?, gitEnv: (tcData['gitEnv'] as Map?)?.cast(), )); @@ -86,7 +85,6 @@ class Backend { git: tcData['git'] as String?, pql: tcData['pql'] as String?, tmux: tcData['tmux'] as String?, - ptyc: tcData['ptyc'] as String?, shell: tcData['shell'] as String?, gitEnv: (tcData['gitEnv'] as Map?)?.cast(), )); diff --git a/lib/kernel/src/backend_entry.dart b/lib/kernel/src/backend_entry.dart index 99d4cccd..b03480f7 100644 --- a/lib/kernel/src/backend_entry.dart +++ b/lib/kernel/src/backend_entry.dart @@ -45,8 +45,8 @@ void backendEntry(BackendBootMessage boot) { late Toolchain toolchain; // Phase 1: resolve toolchain — just find binaries, don't init services. - // We need a project root for ptyc/dugite paths. Use a sensible - // default; the real project comes from project.open. + // We need a project root for dugite paths. Use a sensible default; + // the real project comes from project.open. final resolveRoot = boot.hintRoot ?? Platform.environment['HOME'] ?? '/tmp'; toolchain = Toolchain(); toolchain.applyResolved(resolveToolchainPaths(resolveRoot)); @@ -78,7 +78,7 @@ void backendEntry(BackendBootMessage boot) { final workDir = Directory(projectPath); // Re-resolve toolchain with the actual project root (finds - // dugite in native/dugite/, ptyc in ptyc/bin/, etc.) + // dugite in native/dugite/, etc.) toolchain = Toolchain(); toolchain.applyResolved(resolveToolchainPaths(projectPath)); @@ -138,7 +138,6 @@ Map _serializeToolchain(Toolchain tc) => { 'git': tc.git, 'pql': tc.pql, 'tmux': tc.tmux, - 'ptyc': tc.ptyc, 'shell': tc.shell, 'gitEnv': tc.gitEnv, 'missing': tc.missing, diff --git a/lib/kernel/src/i18n/catalog/builtin.ipc-status_en_us.json b/lib/kernel/src/i18n/catalog/builtin.ipc-status_en_us.json index 04402f8d..effb54f8 100644 --- a/lib/kernel/src/i18n/catalog/builtin.ipc-status_en_us.json +++ b/lib/kernel/src/i18n/catalog/builtin.ipc-status_en_us.json @@ -1,6 +1,6 @@ { "connected": { "translation": "connected" }, - "connected.hint": { "translation": "clide daemon is reachable over the local socket" }, + "connected.hint": { "translation": "backend isolate is reachable" }, "disconnected": { "translation": "disconnected" }, - "disconnected.hint": { "translation": "clide daemon is not running — start it with `clide --daemon`" } + "disconnected.hint": { "translation": "backend isolate is not running" } } diff --git a/lib/kernel/src/i18n/catalog/builtin.terminal_en_us.json b/lib/kernel/src/i18n/catalog/builtin.terminal_en_us.json index 035715e3..5eced900 100644 --- a/lib/kernel/src/i18n/catalog/builtin.terminal_en_us.json +++ b/lib/kernel/src/i18n/catalog/builtin.terminal_en_us.json @@ -3,5 +3,5 @@ "subtitle.spawning": { "translation": "spawning shell…" }, "subtitle.exited": { "translation": "Shell exited." }, "error.unavailable": { "translation": "Terminal unavailable" }, - "error.daemon": { "translation": "Daemon not connected. Start `clide --daemon`." } + "error.daemon": { "translation": "Backend not connected." } } diff --git a/lib/kernel/src/ipc/client.dart b/lib/kernel/src/ipc/client.dart index ec68aa59..db2b7dd4 100644 --- a/lib/kernel/src/ipc/client.dart +++ b/lib/kernel/src/ipc/client.dart @@ -58,7 +58,6 @@ class DaemonClient extends ChangeNotifier { code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'daemon not connected', - hint: 'is `clide --daemon` running?', ), )); } diff --git a/lib/kernel/src/tool_check.dart b/lib/kernel/src/tool_check.dart index e2cdb059..dd5f2a95 100644 --- a/lib/kernel/src/tool_check.dart +++ b/lib/kernel/src/tool_check.dart @@ -5,16 +5,14 @@ import 'package:flutter/foundation.dart'; import '../../src/pty/env.dart'; class ToolCheck extends ChangeNotifier { - bool ptycOk = false; bool pqlOk = false; bool tmuxOk = false; bool gitOk = false; bool checked = false; - bool get allOk => ptycOk && pqlOk && tmuxOk && gitOk; + bool get allOk => pqlOk && tmuxOk && gitOk; List get errors => [ - if (!ptycOk) 'ptyc not found', if (!pqlOk) 'pql not found', if (!tmuxOk) 'tmux not found', if (!gitOk) 'git not found', @@ -24,12 +22,6 @@ class ToolCheck extends ChangeNotifier { static String? workspaceRoot; Future check() async { - final root = workspaceRoot ?? Directory.current.path; - ptycOk = File('$root/native/linux-x64/ptyc').existsSync() || - File('$root/native/macos-arm64/ptyc').existsSync() || - File('$root/native/macos-x64/ptyc').existsSync() || - File('$root/ptyc/bin/ptyc').existsSync() || - _existsOnPath('ptyc'); pqlOk = _existsOnPath('pql'); tmuxOk = _existsOnPath('tmux'); gitOk = _existsOnPath('git'); diff --git a/lib/kernel/src/toolchain.dart b/lib/kernel/src/toolchain.dart index 76074f33..67eececf 100644 --- a/lib/kernel/src/toolchain.dart +++ b/lib/kernel/src/toolchain.dart @@ -16,7 +16,6 @@ class ResolvedPaths { this.git, this.pql, this.tmux, - this.ptyc, this.shell, this.gitEnv, }); @@ -24,7 +23,6 @@ class ResolvedPaths { final String? git; final String? pql; final String? tmux; - final String? ptyc; final String? shell; final Map? gitEnv; } @@ -33,7 +31,6 @@ class Toolchain extends ChangeNotifier { String? _git; String? _pql; String? _tmux; - String? _ptyc; String? _shell; Map? _gitEnv; bool _resolved = false; @@ -41,7 +38,6 @@ class Toolchain extends ChangeNotifier { String get git => _git ?? 'git'; String get pql => _pql ?? 'pql'; String get tmux => _tmux ?? 'tmux'; - String get ptyc => _ptyc ?? 'ptyc'; String get shell => _shell ?? '/bin/bash'; /// Extra environment variables for git (e.g. GIT_EXEC_PATH for dugite). @@ -76,7 +72,6 @@ class Toolchain extends ChangeNotifier { _git = p.git; _pql = p.pql; _tmux = p.tmux; - _ptyc = p.ptyc; _shell = p.shell; _gitEnv = p.gitEnv; _resolved = true; @@ -106,20 +101,10 @@ class Toolchain extends ChangeNotifier { final tmux = _findOnPath('tmux'); final shell = _findOnPath(Platform.environment['SHELL']?.split('/').last ?? 'bash'); - final ptyc = _firstExisting([ - '$workspaceRoot/ptyc/bin/ptyc', - '$workspaceRoot/native/linux-x64/ptyc', - '$workspaceRoot/native/macos-arm64/ptyc', - '$workspaceRoot/native/macos-x64/ptyc', - if (Platform.environment['HOME'] case final home?) '$home/.local/bin/ptyc', - ]) ?? - _findOnPath('ptyc'); - return ResolvedPaths( git: git, pql: pql, tmux: tmux, - ptyc: ptyc, shell: shell, gitEnv: gitEnv, ); @@ -182,14 +167,6 @@ ResolvedPaths resolveToolchainPaths(String workspaceRoot) { git: git, pql: _findOnPathStandalone('pql'), tmux: _findOnPathStandalone('tmux'), - ptyc: _firstExistingStandalone([ - '$workspaceRoot/ptyc/bin/ptyc', - '$workspaceRoot/native/linux-x64/ptyc', - '$workspaceRoot/native/macos-arm64/ptyc', - '$workspaceRoot/native/macos-x64/ptyc', - if (Platform.environment['HOME'] case final home?) '$home/.local/bin/ptyc', - ]) ?? - _findOnPathStandalone('ptyc'), shell: _findOnPathStandalone(Platform.environment['SHELL']?.split('/').last ?? 'bash'), gitEnv: gitEnv, ); diff --git a/lib/lua/src/host.dart b/lib/lua/src/host.dart index ebf60bfb..f46597f7 100644 --- a/lib/lua/src/host.dart +++ b/lib/lua/src/host.dart @@ -6,7 +6,7 @@ class LuaHost { /// Boot the vendored liblua. Throws until Tier 6. static Future start() async { - throw UnsupportedError('Lua runtime lands at Tier 6 (supporter tool sibling of ptyc).'); + throw UnsupportedError('Lua runtime lands at Tier 6.'); } Future dispose() async {} diff --git a/lib/src/daemon/editor_commands.dart b/lib/src/daemon/editor_commands.dart index 0628ef63..337585bf 100644 --- a/lib/src/daemon/editor_commands.dart +++ b/lib/src/daemon/editor_commands.dart @@ -6,7 +6,7 @@ /// editor.read editor.set-selection editor.set-content /// /// Single-word CLI shortcuts (`clide open`, `clide active`, …) map -/// one-to-one onto these in `bin/clide.dart`. +/// one-to-one onto these via the IPC dispatch layer. library; import 'dart:io' show FileSystemException; diff --git a/lib/src/ipc/server.dart b/lib/src/ipc/server.dart deleted file mode 100644 index 1be6a284..00000000 --- a/lib/src/ipc/server.dart +++ /dev/null @@ -1,144 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:clide/src/ipc/envelope.dart'; - -typedef RequestDispatcher = Future Function(IpcRequest request); - -/// Default per-request timeout. A handler that doesn't return within -/// this window gets a `tool_error` response so the connection's read -/// pipeline isn't blocked indefinitely. Long-running commands (git -/// pull/push, large pql queries) can override per-command later. -const Duration _kDefaultRequestTimeout = Duration(seconds: 60); - -/// Unix-socket JSON-lines server. Each connection is an independent -/// bidirectional line-framed stream: client writes requests, daemon -/// writes responses + events on the same socket. -class DaemonServer { - DaemonServer({ - required this.socketPath, - required this.dispatch, - Duration requestTimeout = _kDefaultRequestTimeout, - }) : _requestTimeout = requestTimeout; - - final String socketPath; - final RequestDispatcher dispatch; - final Duration _requestTimeout; - - ServerSocket? _server; - final Set _clients = {}; - - /// Broadcast [event] to every currently-connected client. Sockets - /// that error on write are dropped — the client's read side will - /// notice the close. Errors are logged so silent event loss is - /// debuggable. - void broadcast(IpcEvent event) { - final line = event.encode(); - for (final c in List.from(_clients)) { - try { - c.writeln(line); - } catch (e) { - stderr.writeln('clide daemon: broadcast write failed (${event.subsystem}.${event.kind}): $e'); - _clients.remove(c); - } - } - } - - Future start() async { - final addr = InternetAddress(socketPath, type: InternetAddressType.unix); - try { - _server = await ServerSocket.bind(addr, 0); - } on SocketException { - // Either a stale socket from a prior crash, or a live daemon. - // Probe by trying to connect — if a live peer answers, refuse - // to start so we don't rip its socket out. - try { - final probe = await Socket.connect(addr, 0).timeout(const Duration(milliseconds: 200)); - await probe.close(); - throw StateError('clide daemon already running at $socketPath'); - } on TimeoutException { - // No one answered — proceed to unlink and rebind. - } on SocketException { - // No one listening — proceed to unlink and rebind. - } - try { - await File(socketPath).delete(); - } catch (_) {} - _server = await ServerSocket.bind(addr, 0); - } - stderr.writeln('clide daemon listening on $socketPath'); - _server!.listen(_handleClient, onError: (e) { - stderr.writeln('clide daemon accept error: $e'); - }); - } - - Future stop() async { - for (final c in List.from(_clients)) { - await c.close(); - } - _clients.clear(); - await _server?.close(); - _server = null; - try { - await File(socketPath).delete(); - } catch (_) {} - } - - void _handleClient(Socket client) { - _clients.add(client); - client.cast>().transform(utf8.decoder).transform(const LineSplitter()).listen( - (line) => _handleLine(client, line), - onDone: () => _clients.remove(client), - onError: (Object e) { - stderr.writeln('clide daemon client error: $e'); - _clients.remove(client); - }, - cancelOnError: true, - ); - } - - Future _handleLine(Socket client, String line) async { - if (line.isEmpty) return; - IpcMessage? msg; - try { - msg = IpcMessage.decode(line); - } on FormatException catch (e) { - stderr.writeln('clide daemon: bad line from client: $e'); - return; - } - if (msg is! IpcRequest) return; - IpcResponse resp; - try { - resp = await dispatch(msg).timeout(_requestTimeout); - } on TimeoutException { - stderr.writeln('clide daemon: dispatch timeout for ${msg.cmd} (${_requestTimeout.inSeconds}s)'); - resp = IpcResponse.err( - id: msg.id, - error: IpcError( - code: 2, - kind: 'tool_error', - message: 'request timed out after ${_requestTimeout.inSeconds}s: ${msg.cmd}', - ), - ); - } catch (e, st) { - stderr.writeln('clide daemon: dispatch error for ${msg.cmd}: $e\n$st'); - resp = IpcResponse.err( - id: msg.id, - error: IpcError( - code: 2, - kind: 'tool_error', - message: 'dispatch failed: $e', - ), - ); - } - try { - client.writeln(resp.encode()); - } catch (e) { - // Client disconnected mid-dispatch — drop it so future events - // don't try to write to a dead socket. - stderr.writeln('clide daemon: response write failed (${msg.cmd}): $e'); - _clients.remove(client); - } - } -} diff --git a/lib/src/panes/pane.dart b/lib/src/panes/pane.dart index 97199eee..a057b2aa 100644 --- a/lib/src/panes/pane.dart +++ b/lib/src/panes/pane.dart @@ -2,8 +2,8 @@ /// travels cleanly into the Flutter app (which can't depend on /// `dart:ffi`-using code for the web build). /// -/// The daemon's [PaneRegistry] keeps a parallel `PtySession` keyed on -/// [id] and mutates [isClosed] when the session exits. +/// [PaneRegistry] keeps a parallel [NativePty] keyed on [id] and +/// mutates [isClosed] when the session exits. library; /// Kind of a pane. Keep this enum small and explicit — each kind diff --git a/lib/src/panes/registry.dart b/lib/src/panes/registry.dart index e031414e..850ded74 100644 --- a/lib/src/panes/registry.dart +++ b/lib/src/panes/registry.dart @@ -1,6 +1,6 @@ -/// [PaneRegistry] — daemon-side state for all live panes. +/// [PaneRegistry] — backend-side state for all live panes. /// -/// Owns the [PtySession] per pane, generates `p_N` ids, and forwards +/// Owns the [NativePty] per pane, generates `p_N` ids, and forwards /// pty output + lifecycle changes as IPC events via a [DaemonEventSink]. /// Pane commands (pane.spawn / list / write / resize / close) resolve /// against this registry; extension UIs subscribe to the emitted events. @@ -31,10 +31,6 @@ class PaneRegistry { Pane? get(String id) => _panes[id]; /// Spawn a child under a PTY and wire its output to events. - /// - /// [ptycPath] is plumbed through to [PtySession.spawn]; callers that - /// have a dev-built `ptyc/bin/ptyc` or a non-PATH install can point - /// at it explicitly. Future spawn({ required PaneKind kind, required List argv, @@ -49,7 +45,7 @@ class PaneRegistry { final arguments = argv.length > 1 ? argv.sublist(1) : const []; // Merge the caller's env on top of the process environment + - // terminal defaults, matching the old ptyc contract. + // Terminal defaults for the PTY child. final fullEnv = { ...Platform.environment, 'TERM': 'xterm-256color', diff --git a/lib/src/pty/env.dart b/lib/src/pty/env.dart index 7117d2ad..e05d53d7 100644 --- a/lib/src/pty/env.dart +++ b/lib/src/pty/env.dart @@ -37,10 +37,10 @@ String _buildExpandedPath() { return [...missing, ...existing].join(':'); } -/// Base env clide's daemon builds for every PTY child. Callers merge -/// with the user's environment before passing to `ptyc` — a child that -/// needs user env like `HOME` / `USER` / `SHELL` still gets them; the -/// keys here override the ones the child cares about. +/// Base env clide builds for every PTY child. Callers merge with the +/// user's environment — a child that needs user env like `HOME` / +/// `USER` / `SHELL` still gets them; the keys here override the ones +/// the child cares about. const Map clidePtyEnvDefaults = { 'TERM': 'xterm-256color', 'COLORTERM': 'truecolor', diff --git a/lib/src/pty/errors.dart b/lib/src/pty/errors.dart index dde62099..2cd2bc01 100644 --- a/lib/src/pty/errors.dart +++ b/lib/src/pty/errors.dart @@ -5,9 +5,9 @@ /// core library stays Flutter-free per D-005. library; -/// A PTY operation failed. [op] identifies the step (`recvmsg`, -/// `socketpair`, `ptyc`, etc.); [errno] is POSIX errno when the -/// failure came from a syscall, otherwise `null`. +/// A PTY operation failed. [op] identifies the step (`forkpty`, +/// `read`, `ioctl`, etc.); [errno] is POSIX errno when the failure +/// came from a syscall, otherwise `null`. class PtyException implements Exception { const PtyException(this.op, this.message, {this.errno}); diff --git a/lib/src/pty/ffi/libc.dart b/lib/src/pty/ffi/libc.dart index 0985d801..36cdcc0d 100644 --- a/lib/src/pty/ffi/libc.dart +++ b/lib/src/pty/ffi/libc.dart @@ -1,9 +1,7 @@ /// Raw FFI bindings to the libc functions the PTY wrapper needs. /// -/// `dart:io` covers neither `socketpair(2)`, `recvmsg(2)` with ancillary -/// data, nor read/write on arbitrary file descriptors — the three -/// things the [`ptyc`](../../../ptyc/README.md) fd-transfer protocol -/// requires. FFI is the minimum tool for the job. +/// `dart:io` doesn't expose `forkpty`, `read`/`write` on raw fds, +/// `ioctl`, or `poll` — FFI is the minimum tool for the job. /// /// Linux + macOS only for now. Windows is covered by platform checks /// higher up; when Windows support lands it'll need a parallel binding diff --git a/lib/src/pty/ffi/scm_rights.dart b/lib/src/pty/ffi/scm_rights.dart deleted file mode 100644 index 82bc981e..00000000 --- a/lib/src/pty/ffi/scm_rights.dart +++ /dev/null @@ -1,122 +0,0 @@ -/// Receive a single file descriptor over a unix socket via -/// `SCM_RIGHTS` ancillary data. -/// -/// Pairs with `ptyc`'s `send_fd()`: the peer sends one byte of payload -/// plus the fd in cmsg; this function reads both and returns the fd. -library; - -import 'dart:ffi' as ffi; -import 'dart:io' show Platform; - -import 'package:ffi/ffi.dart' as pkg_ffi; - -import '../errors.dart'; -import 'libc.dart' as libc; - -/// Blocks on [socketFd] waiting for a single-byte payload carrying a -/// fd over `SCM_RIGHTS`. Returns the received fd on success. -/// -/// Throws a [PlatformException] if `recvmsg` fails or the peer sends -/// no ancillary data. -int recvFd(int socketFd) { - // Layout: one-byte payload buffer + CMSG_SPACE(sizeof(int)) control - // buffer. `CMSG_SPACE` is just `ALIGN(sizeof(cmsghdr)) + ALIGN(data)` - // — for a single int that's 16 + 4 rounded up to 8 = 24 on 64-bit, - // but we over-allocate to 32 to be safe across platforms. - const payloadLen = 1; - const controlLen = 32; - - final payload = pkg_ffi.calloc(payloadLen); - final control = pkg_ffi.calloc(controlLen); - final iov = pkg_ffi.calloc(); - - try { - iov.ref.iov_base = payload; - iov.ref.iov_len = payloadLen; - - int received; - int msgControllen; - - if (Platform.isMacOS) { - final msg = pkg_ffi.calloc(); - try { - msg.ref.msg_name = ffi.nullptr; - msg.ref.msg_namelen = 0; - msg.ref.msg_iov = iov; - msg.ref.msg_iovlen = 1; - msg.ref.msg_control = control.cast(); - msg.ref.msg_controllen = controlLen; - msg.ref.msg_flags = 0; - - while (true) { - received = libc.recvmsgDarwin(socketFd, msg, 0); - if (received >= 0) break; - final err = libc.errno; - if (err == 4 /* EINTR */) continue; - throw PtyException('recvmsg', 'recvmsg failed', errno: err); - } - msgControllen = msg.ref.msg_controllen; - } finally { - pkg_ffi.calloc.free(msg); - } - } else { - final msg = pkg_ffi.calloc(); - try { - msg.ref.msg_name = ffi.nullptr; - msg.ref.msg_namelen = 0; - msg.ref.msg_iov = iov; - msg.ref.msg_iovlen = 1; - msg.ref.msg_control = control.cast(); - msg.ref.msg_controllen = controlLen; - msg.ref.msg_flags = 0; - - while (true) { - received = libc.recvmsgLinux(socketFd, msg, 0); - if (received >= 0) break; - final err = libc.errno; - if (err == 4 /* EINTR */) continue; - throw PtyException('recvmsg', 'recvmsg failed', errno: err); - } - msgControllen = msg.ref.msg_controllen; - } finally { - pkg_ffi.calloc.free(msg); - } - } - - if (received == 0 || msgControllen < 16) { - throw const PtyException( - 'recvmsg', - 'peer closed without sending ancillary data', - ); - } - - // Parse the first cmsghdr out of the control buffer. On macOS, - // cmsg_len is socklen_t (4 bytes); on Linux it's size_t (8 bytes). - int cmsgLevel, cmsgType, dataOffset; - if (Platform.isMacOS) { - final hdr = control.cast().ref; - cmsgLevel = hdr.cmsg_level; - cmsgType = hdr.cmsg_type; - dataOffset = ffi.sizeOf(); - } else { - final hdr = control.cast().ref; - cmsgLevel = hdr.cmsg_level; - cmsgType = hdr.cmsg_type; - dataOffset = ffi.sizeOf(); - } - - if (cmsgLevel != libc.solSocket || cmsgType != libc.scmRights) { - throw PtyException( - 'recvmsg', - 'unexpected cmsg level=$cmsgLevel type=$cmsgType', - ); - } - - final fdPtr = (control + dataOffset).cast(); - return fdPtr.value; - } finally { - pkg_ffi.calloc.free(iov); - pkg_ffi.calloc.free(control); - pkg_ffi.calloc.free(payload); - } -} diff --git a/lib/src/pty/native_pty.dart b/lib/src/pty/native_pty.dart index 221c6ac3..586bd6b2 100644 --- a/lib/src/pty/native_pty.dart +++ b/lib/src/pty/native_pty.dart @@ -1,8 +1,7 @@ -/// Native PTY via forkpty() — replaces the ptyc helper binary. +/// Native PTY via forkpty(). /// /// Uses Dart FFI to call forkpty() directly. The master fd stays -/// in-process (no socketpair, no SCM_RIGHTS). The reader isolate -/// uses poll() for clean shutdown. +/// in-process. The reader isolate uses poll() for clean shutdown. /// /// Based on the pty-spike proof-of-concept. Platform-aware: /// macOS: forkpty in libSystem (DynamicLibrary.process) @@ -82,8 +81,6 @@ const _kWnohang = 1; // -- NativePty -------------------------------------------------------------- /// A pseudo-terminal backed by forkpty() via Dart FFI. -/// -/// Drop-in replacement for the old ptyc-based PtySession. class NativePty { final int _fd; final int pid; diff --git a/lib/src/pty/session.dart b/lib/src/pty/session.dart deleted file mode 100644 index 2fb7b750..00000000 --- a/lib/src/pty/session.dart +++ /dev/null @@ -1,442 +0,0 @@ -/// [PtySession] — high-level PTY lifecycle. -/// -/// Spawns `ptyc` with the given argv/cwd/env, receives the master fd -/// via `SCM_RIGHTS`, and exposes: -/// -/// - [output] — a broadcast stream of bytes read from the child. -/// - [write] — send bytes to the child's stdin. -/// - [resize] — change the child's window size. -/// - [kill] — send a signal to the child. -/// - [close] — close the master fd and stop reading. -/// -/// Reading happens in a background isolate that loops on blocking -/// `read(fd)` calls and posts bytes to the main isolate via a -/// [ReceivePort]. Closing the fd from the main isolate causes `read()` -/// to return EBADF; the isolate sees that and exits. -library; - -import 'dart:async'; -import 'dart:convert'; -import 'dart:ffi' as ffi; -import 'dart:io'; -import 'dart:isolate'; -import 'dart:typed_data'; - -import 'package:ffi/ffi.dart' as pkg_ffi; - -import 'env.dart'; -import 'errors.dart'; -import 'ffi/libc.dart' as libc; -import 'ffi/scm_rights.dart' as scm; - -class _RecvFdArgs { - const _RecvFdArgs(this.socketFd, this.sendPort); - final int socketFd; - final SendPort sendPort; -} - -/// A running PTY child plus its master-fd plumbing. -class PtySession { - PtySession._({ - required this.pid, - required int masterFd, - }) : _masterFd = masterFd { - _startReader(); - } - - /// The spawned child's PID (not ptyc's — ptyc has already exited). - final int pid; - - int _masterFd; - - final _outputCtrl = StreamController.broadcast(); - final _readerExited = Completer(); - Isolate? _readerIsolate; - ReceivePort? _readerPort; - - /// Broadcast stream of raw bytes from the child's stdout/stderr. - Stream get output => _outputCtrl.stream; - - /// Whether the session is still alive. - bool get isClosed => _masterFd < 0; - - /// Spawn a child under a PTY. - /// - /// [argv] must be non-empty; [argv[0]] is resolved via PATH. [env] - /// is merged onto the parent process env via [mergePtyEnv] so - /// terminal children inherit `HOME` / `USER` while clide's - /// true-colour defaults still take effect. - /// - /// [ptycPath] defaults to looking for `ptyc` on PATH; dev setups - /// that haven't `make install`'d the helper can point at the - /// development build under `ptyc/bin/ptyc`. - static Future spawn({ - required List argv, - String? cwd, - Map? env, - int cols = 80, - int rows = 24, - String ptycPath = 'ptyc', - }) async { - if (argv.isEmpty) { - throw ArgumentError.value(argv, 'argv', 'must be non-empty'); - } - - // socketpair for the fd transfer. - final sv = pkg_ffi.calloc(2); - int parentSock = -1; - int childSock = -1; - Process? proc; - try { - final rc = libc.socketpair(libc.afUnix, libc.sockStream, 0, sv); - if (rc < 0) { - throw PtyException('socketpair', 'socketpair failed', errno: libc.errno); - } - parentSock = sv[0]; - childSock = sv[1]; - - // Build the JSON request for ptyc. - final req = _buildRequest( - argv: argv, - cwd: cwd, - env: mergePtyEnv( - processEnv: Platform.environment, - overrides: env, - ), - cols: cols, - rows: rows, - ); - - // Launch ptyc. We pass childSock to it via PTYC_SOCK_FD so ptyc - // reads it from env rather than having to place it at fd 3 - // specifically — Dart's Process.start doesn't give us fine - // control over child fd layout. - proc = await Process.start( - ptycPath, - const [], - environment: { - ...Platform.environment, - 'PTYC_SOCK_FD': childSock.toString(), - }, - // Inherit the socket fd into the child. Dart exposes this via - // a private API in recent versions; until it lands we rely on - // default behaviour (Process.start doesn't close arbitrary - // fds inherited from the parent's open-fd set). - mode: ProcessStartMode.normal, - ); - - // Send the request and close stdin so ptyc sees EOF. - proc.stdin.add(req); - await proc.stdin.close(); - - // Receive the master fd over the parent side of the socketpair. - // recvFd blocks until ptyc sends — run in a child isolate so the - // calling isolate's event loop stays responsive. - final int masterFd; - try { - masterFd = await _recvFdAsync(parentSock); - } catch (_) { - proc.kill(); - rethrow; - } - - // Once we own masterFd, every error path below must close it - // before rethrowing. Wrap the rest of the spawn in its own - // try/catch so the cleanup is centralized. - try { - libc.setWinsize(masterFd, cols, rows); - - final stdoutLine = await proc.stdout.transform(const Utf8Decoder()).transform(const LineSplitter()).first.timeout(const Duration(seconds: 5)); - final pid = _extractPid(stdoutLine); - - final code = await proc.exitCode; - if (code != 0) { - final stderr = await proc.stderr.transform(const Utf8Decoder()).join(); - libc.close(masterFd); - throw PtyException('ptyc', 'ptyc exited with code $code: $stderr'); - } - - return PtySession._(pid: pid, masterFd: masterFd); - } catch (_) { - libc.close(masterFd); - rethrow; - } - } finally { - // parent keeps its own fd until the session is closed; ptyc-side - // fd is released either way (ptyc has exited by now). - if (childSock >= 0) libc.close(childSock); - if (parentSock >= 0) libc.close(parentSock); - pkg_ffi.calloc.free(sv); - } - } - - /// Send bytes to the child's stdin. Loops on short writes; throws - /// [PtyException] (with errno) on failure. Returns total bytes - /// written, which equals [bytes.length] on success. - int write(List bytes) { - if (isClosed) return 0; - final buf = pkg_ffi.calloc(bytes.length); - try { - for (var i = 0; i < bytes.length; i++) { - buf[i] = bytes[i]; - } - var written = 0; - while (written < bytes.length) { - final n = libc.write( - _masterFd, - buf + written, - bytes.length - written, - ); - if (n < 0) { - final err = libc.errno; - if (err == 4 /* EINTR */) continue; - throw PtyException('write', 'write to PTY failed', errno: err); - } - if (n == 0) break; - written += n; - } - return written; - } finally { - pkg_ffi.calloc.free(buf); - } - } - - /// Resize the child's terminal. - void resize({required int cols, required int rows}) { - if (isClosed) return; - libc.setWinsize(_masterFd, cols, rows); - } - - /// Send a signal to the child. Uses `Process.killPid` for now; a - /// future pass can deliver signals via the PTY's foreground process - /// group so Ctrl-C from the UI works naturally. - bool kill([ProcessSignal signal = ProcessSignal.sigterm]) { - return Process.killPid(pid, signal); - } - - /// Close the session. Signals the child, waits briefly for the - /// reader isolate to see EOF on the master fd (natural wakeup), and - /// then closes + force-kills whatever's still around. - /// - /// Ordering matters: closing the master fd alone does **not** unblock - /// a `read()` already in flight on Linux — the blocked syscall holds - /// a reference to the kernel file. Killing the child causes the PTY - /// to return EOF on master, which is the clean way to wake the - /// reader. See D-005 notes; a belt-and-braces `poll()` + self-pipe - /// wake path is possible but not worth the FFI surface at Tier 1. - Future close() async { - if (isClosed) return; - final fd = _masterFd; - _masterFd = -1; - - // 1. Ask the child nicely so the shell can run its exit traps. - try { - Process.killPid(pid, ProcessSignal.sigterm); - } catch (_) { - // Already gone — fine. - } - - // 2. Give the reader isolate up to ~500ms to see EOF and signal - // back via its 'eof' message (set by the existing listener, - // which completes _readerExited). - await _readerExited.future.timeout( - const Duration(milliseconds: 500), - onTimeout: () {}, - ); - - // 3. Belt and braces: SIGKILL the child, close the master, and - // force-kill the isolate regardless. Any still-pending read() - // returns on close via EIO; future reads return EBADF. - try { - Process.killPid(pid, ProcessSignal.sigkill); - } catch (_) {} - libc.close(fd); - _readerPort?.close(); - _readerIsolate?.kill(priority: Isolate.immediate); - _readerPort = null; - _readerIsolate = null; - - if (!_outputCtrl.isClosed) await _outputCtrl.close(); - } - - /// Run recvFd in a child isolate so the blocking FFI call doesn't - /// stall the calling isolate's event loop. - static Future _recvFdAsync(int socketFd) async { - final port = ReceivePort(); - Isolate? iso; - try { - iso = await Isolate.spawn(_recvFdEntry, _RecvFdArgs(socketFd, port.sendPort)); - final result = await port.first; - if (result is int) return result; - throw PtyException('recvFd', '$result'); - } finally { - iso?.kill(priority: Isolate.immediate); - port.close(); - } - } - - static void _recvFdEntry(_RecvFdArgs args) { - try { - final fd = scm.recvFd(args.socketFd); - args.sendPort.send(fd); - } catch (e) { - args.sendPort.send('error: $e'); - } - } - - // ---------------------------------------------------------------- // - - void _startReader() { - final port = ReceivePort(); - _readerPort = port; - - port.listen((dynamic msg) { - if (msg is Uint8List) { - if (!_outputCtrl.isClosed) _outputCtrl.add(msg); - } else if (msg == 'eof') { - if (!_readerExited.isCompleted) _readerExited.complete(); - } - }); - - Isolate.spawn<_ReaderArgs>( - _readerEntrypoint, - _ReaderArgs(fd: _masterFd, sendPort: port.sendPort), - ).then( - (iso) => _readerIsolate = iso, - onError: (Object e) { - // Spawn failure leaves the session unable to ever produce - // output. Surface the error and mark the controller closed - // so consumers don't hang waiting on the stream. - if (!_outputCtrl.isClosed) { - _outputCtrl.addError(PtyException('reader-spawn', '$e')); - _outputCtrl.close(); - } - if (!_readerExited.isCompleted) _readerExited.complete(); - }, - ); - } - - // -- request builder ------------------------------------------------------ - - static List _buildRequest({ - required List argv, - required String? cwd, - required Map env, - required int cols, - required int rows, - }) { - // Minimal JSON emitter — our request never contains non-ASCII, - // so we only need to escape ", \, and the standard control chars. - final sb = StringBuffer('{'); - sb.write('"argv":['); - for (var i = 0; i < argv.length; i++) { - if (i > 0) sb.write(','); - sb.write(_json(argv[i])); - } - sb.write(']'); - if (cwd != null) { - sb.write(',"cwd":${_json(cwd)}'); - } - sb.write(',"env":{'); - var first = true; - env.forEach((k, v) { - if (!first) sb.write(','); - first = false; - sb.write('${_json(k)}:${_json(v)}'); - }); - sb.write('}'); - sb.write(',"cols":$cols,"rows":$rows'); - sb.write('}'); - return utf8.encode(sb.toString()); - } - - static String _json(String s) { - final b = StringBuffer('"'); - for (var i = 0; i < s.length; i++) { - final c = s.codeUnitAt(i); - switch (c) { - case 0x22: - b.write(r'\"'); - break; - case 0x5c: - b.write(r'\\'); - break; - case 0x08: - b.write(r'\b'); - break; - case 0x09: - b.write(r'\t'); - break; - case 0x0a: - b.write(r'\n'); - break; - case 0x0c: - b.write(r'\f'); - break; - case 0x0d: - b.write(r'\r'); - break; - default: - if (c < 0x20) { - b.write('\\u${c.toRadixString(16).padLeft(4, '0')}'); - } else { - b.writeCharCode(c); - } - } - } - b.write('"'); - return b.toString(); - } - - static int _extractPid(String json) { - // Narrow regex is enough — ptyc's success envelope is known-shape. - final m = RegExp(r'"pid"\s*:\s*(\d+)').firstMatch(json); - if (m == null) { - throw PtyException('ptyc', 'no pid in ptyc response: $json'); - } - return int.parse(m.group(1)!); - } -} - -// --------------------------------------------------------------------------- -// Reader isolate -// --------------------------------------------------------------------------- - -class _ReaderArgs { - const _ReaderArgs({required this.fd, required this.sendPort}); - final int fd; - final SendPort sendPort; -} - -/// Runs in a separate isolate. Loops on blocking `read(fd)` and posts -/// each chunk back to the main isolate as a `Uint8List`. Exits on -/// EOF, close, or error. -void _readerEntrypoint(_ReaderArgs args) { - const chunk = 65536; - final buf = pkg_ffi.calloc(chunk); - try { - while (true) { - final n = libc.read(args.fd, buf, chunk); - if (n > 0) { - final bytes = Uint8List(n); - for (var i = 0; i < n; i++) { - bytes[i] = buf[i]; - } - args.sendPort.send(bytes); - } else if (n == 0) { - // child closed pty → EOF - args.sendPort.send('eof'); - return; - } else { - final err = libc.errno; - if (err == 4 /* EINTR */) continue; - // 9=EBADF (fd closed from main), 5=EIO (child exited on - // Linux). Either way, we're done. - args.sendPort.send('eof'); - return; - } - } - } finally { - pkg_ffi.calloc.free(buf); - } -} diff --git a/lib/test_app.dart b/lib/test_app.dart index bf3979e5..9b292479 100644 --- a/lib/test_app.dart +++ b/lib/test_app.dart @@ -34,7 +34,6 @@ import 'src/daemon/pane_commands.dart'; import 'src/ipc/envelope.dart'; import 'src/panes/event_sink.dart'; import 'src/panes/registry.dart'; -import 'src/pty/session.dart'; import 'src/daemon/dispatcher.dart'; import 'src/pty/env.dart' show expandedPath; @@ -124,7 +123,6 @@ class _ClideTestAppState extends State { _log('toolchain.git', tc.git); _log('toolchain.pql', tc.pql); _log('toolchain.tmux', tc.tmux); - _log('toolchain.ptyc', tc.ptyc); _log('toolchain.shell', tc.shell); _log('toolchain.missing', tc.missing.isEmpty ? 'none' : tc.missing.join(', ')); _say(''); @@ -132,14 +130,12 @@ class _ClideTestAppState extends State { await _testExists('git', tc.git); await _testExists('pql', tc.pql); await _testExists('tmux', tc.tmux); - await _testExists('ptyc', tc.ptyc); await _testExists('shell', tc.shell); _say(''); await _testExec('git --version', tc.git, ['--version'], workDir); await _testExec('pql --version', tc.pql, ['--version'], workDir); await _testExec('tmux -V', tc.tmux, ['-V'], workDir); - await _testExec('ptyc (no args)', tc.ptyc, [], workDir); await _testExec('shell --version', tc.shell, ['--version'], workDir); _say(''); @@ -191,17 +187,6 @@ class _ClideTestAppState extends State { return 'exit=${r.exitCode} ${(r.stdout as String).trim()}'; }); - // ptyc stdin/stdout test — send a valid request, verify JSON response - await _testAsync('ptyc spawn echo', () async { - final proc = await Process.start(tc.ptyc, []); - // Send a request for /bin/echo — simplest possible child - proc.stdin.write('{"argv":["/bin/echo","hello"],"cwd":"/tmp","env":{},"cols":80,"rows":24}'); - await proc.stdin.close(); - final stdout = await proc.stdout.transform(const SystemEncoding().decoder).join(); - final exitCode = await proc.exitCode; - return 'exit=$exitCode stdout=${stdout.trim().split('\n').first}'; - }); - _say(''); } @@ -395,93 +380,6 @@ class _ClideTestAppState extends State { return 'exit=$exit stderr=${stderr.trim()}'; }); - // Direct PtySession test — bypasses IPC, tests fd transfer + reader. - await _testAsync('PtySession.spawn direct', () async { - final session = await PtySession.spawn( - argv: [tc.shell, '-c', 'echo DIRECT_PTY_TEST'], - cwd: workDir, - ptycPath: tc.ptyc, - ); - _say(' session pid=${session.pid} masterFd exists'); - final bytes = []; - final done = Completer(); - session.output.listen( - (chunk) { - bytes.addAll(chunk); - _say(' got ${chunk.length} bytes'); - }, - onDone: () { - _say(' stream done'); - if (!done.isCompleted) done.complete(); - }, - onError: (e) => _say(' stream error: $e'), - ); - await done.future.timeout(const Duration(seconds: 5), onTimeout: () { - _say(' timeout waiting for output, got ${bytes.length} bytes so far'); - }); - await session.close(); - final output = utf8.decode(bytes, allowMalformed: true); - final ok = output.contains('DIRECT_PTY_TEST'); - return ok ? 'output=$output' : 'no marker in ${bytes.length} bytes: ${output.substring(0, output.length.clamp(0, 100))}'; - }); - - if (!Platform.isMacOS) { - // Additional direct PtySession tests (Linux only — no merged thread). - - // Test 1: spawn /bin/echo via PtySession, read output - await _testAsync('pty spawn echo', () async { - final session = await PtySession.spawn( - argv: ['/bin/echo', 'CLIDE_PTY_TEST_OK'], - cwd: workDir, - ptycPath: tc.ptyc, - ); - final bytes = []; - final done = Completer(); - session.output.listen(bytes.addAll, onDone: () => done.complete()); - await done.future.timeout(const Duration(seconds: 5)); - await session.close(); - final output = utf8.decode(bytes, allowMalformed: true); - final ok = output.contains('CLIDE_PTY_TEST_OK'); - return ok ? 'output contains marker' : 'marker not found in ${output.length} bytes'; - }); - - // Test 2: spawn shell, write a command, verify output - await _testAsync('pty spawn shell', () async { - final session = await PtySession.spawn( - argv: [tc.shell, '-c', 'echo CLIDE_SHELL_TEST'], - cwd: workDir, - ptycPath: tc.ptyc, - ); - final bytes = []; - final done = Completer(); - session.output.listen(bytes.addAll, onDone: () => done.complete()); - await done.future.timeout(const Duration(seconds: 5)); - await session.close(); - final output = utf8.decode(bytes, allowMalformed: true); - final ok = output.contains('CLIDE_SHELL_TEST'); - return ok ? 'shell output contains marker' : 'marker not found in ${output.length} bytes'; - }); - - // Test 3: spawn interactive shell, write to stdin, verify file creation - await _testAsync('pty write to child', () async { - final marker = '/tmp/clide-pty-test-${DateTime.now().millisecondsSinceEpoch}'; - final session = await PtySession.spawn( - argv: [tc.shell], - cwd: workDir, - ptycPath: tc.ptyc, - ); - session.write(utf8.encode('touch $marker && exit\n')); - final bytes = []; - final done = Completer(); - session.output.listen(bytes.addAll, onDone: () => done.complete()); - await done.future.timeout(const Duration(seconds: 5)); - await session.close(); - final fileCreated = File(marker).existsSync(); - if (fileCreated) File(marker).deleteSync(); - return fileCreated ? 'file created + cleaned up' : 'file not created'; - }); - } // end !Platform.isMacOS - _say(''); } diff --git a/macos/Runner/DebugProfile.entitlements.template b/macos/Runner/DebugProfile.entitlements.template index cceb2926..402c8b75 100644 --- a/macos/Runner/DebugProfile.entitlements.template +++ b/macos/Runner/DebugProfile.entitlements.template @@ -28,7 +28,6 @@ (allow process-exec* (literal "__SHELL__")) (allow process-exec* (literal "/usr/bin/which")) (allow process-exec* (subpath "__HOMEDIR__/.local/bin")) - (allow process-exec* (subpath "__PROJECTS__/clide/ptyc/bin")) (allow process-exec* (subpath "__PROJECTS__/clide/native/dugite")) (allow process-fork) (allow file-read* file-write* (subpath "__PROJECTS__")) diff --git a/ptyc/Makefile b/ptyc/Makefile deleted file mode 100644 index fb55443f..00000000 --- a/ptyc/Makefile +++ /dev/null @@ -1,27 +0,0 @@ -# ptyc — PTY-spawn helper for clide. -# -# Single source file, libc only. No configure step. Builds everywhere a -# POSIX-y C compiler + unix sockets exist. - -CC ?= cc -CFLAGS ?= -std=c11 -Wall -Wextra -Wpedantic -Werror -O2 -D_FORTIFY_SOURCE=2 -LDFLAGS ?= - -BIN := bin/ptyc - -.PHONY: all -all: $(BIN) - -$(BIN): ptyc.c | bin - $(CC) $(CFLAGS) -o $@ $< $(LDFLAGS) - -bin: - mkdir -p bin - -.PHONY: test -test: $(BIN) - ./test_ptyc.sh - -.PHONY: clean -clean: - rm -rf bin *.o diff --git a/ptyc/README.md b/ptyc/README.md deleted file mode 100644 index 9c399165..00000000 --- a/ptyc/README.md +++ /dev/null @@ -1,127 +0,0 @@ -# ptyc - -Small POSIX helper that spawns a child process under a PTY and hands -the master fd back to its caller. Language-agnostic; usable from any -program that can fork a subprocess and receive a file descriptor over a -unix socket. - -Clide uses it for every PTY it owns (terminal panes, Claude sessions, -tmux wrappers, LSP servers, debug adapters). See -[`D-005`](../decisions/architecture.md#d-005-dart-core-sidecar-dissolved-ptyc-as-pql-peer) -for the architectural rationale; ptyc is a peer of -[`pql`](https://github.com/postmeridiem/pql), not a clide subsystem. - -## Build - -```sh -make # produces bin/ptyc -make test # runs test_ptyc.sh against the built binary -make clean -``` - -No third-party dependencies. `CC`, `CFLAGS`, and `LDFLAGS` are -overrideable in the usual way. - -## Wire contract - -ptyc is a one-shot helper. The caller: - -1. Creates a `socketpair(AF_UNIX, SOCK_STREAM, 0)`. -2. Launches `ptyc` as a subprocess, passing one end of the socket to - the child as **file descriptor 3** (the default) or whatever fd is - given in the `PTYC_SOCK_FD` environment variable. The other end of - the socket stays with the caller. The env-var override exists - because some language runtimes (Python's `subprocess` with - `stdout=PIPE`, for example) shuffle their own pipe fds through the - low numbers and it's cheaper for the caller to pick a higher fd - than to dup2 it down. -3. Writes the request as **JSON on stdin** and closes stdin (EOF - signals end of request). -4. Receives the master PTY fd over the socket via **`SCM_RIGHTS`** - ancillary data (with a single-byte `'x'` payload so the receiver - knows when to `recvmsg`). -5. Reads the success response from stdout (single line of JSON) and - reaps the exited `ptyc` process. - -### Request - -JSON object on stdin. All fields optional except `argv`. - -```json -{ - "argv": ["bash", "-l"], - "cwd": "/home/me/work", - "env": {"TERM": "xterm-256color", "LANG": "en_US.UTF-8"}, - "cols": 80, - "rows": 24 -} -``` - -- `argv` — required, non-empty array of strings. `argv[0]` is resolved - via `PATH`. -- `cwd` — optional. If omitted, the child inherits ptyc's cwd. -- `env` — optional object. If present, the child's environment is - **replaced** with exactly the keys given (ptyc does `clearenv()` and - then `putenv` per entry). If absent, the child inherits ptyc's - environment. This is a deliberate choice: the daemon is expected to - build the env it wants, not rely on a merge. -- `cols`, `rows` — optional. Default `80` × `24`. Applied via - `TIOCSWINSZ` before fork. - -### Success response (stdout) - -```json -{"ok":true,"pid":12345} -``` - -One line, trailing newline. The master PTY fd is already on the socket -by the time stdout is written. `pid` is the spawned child's PID — the -caller is responsible for `waitpid`'ing it when appropriate. - -### Error response (stderr) - -```json -{"ok":false,"error":"exec: No such file or directory","errno":2} -``` - -Written on stderr. No fd is sent. ptyc exits with a non-zero code. - -### Exit codes - -| Code | Meaning | -|------|---------| -| `0` | Success — fd sent, success response on stdout. | -| `1` | Bad request — JSON parse error, missing `argv`, bad field values. | -| `2` | Syscall failed — fork, exec, PTY open, `sendmsg`, etc. Check `errno` in the response. | - -## Limits - -Compile-time caps, deliberately small: - -- `MAX_ARGV` = 64 entries -- `MAX_ENV` = 256 entries -- `MAX_INPUT` = 64 KiB request size - -These are far above what any reasonable pane invocation needs; if you -hit them you're holding ptyc wrong. Edit the `#define`s in `ptyc.c` and -rebuild. - -## Security notes - -- The JSON parser is scoped to the shape above. It rejects anything - else. Strings support the standard `\"` `\\` `\/` `\b` `\f` `\n` - `\r` `\t` escapes and ASCII-range `\uXXXX`. Non-ASCII Unicode escapes - (and surrogate pairs) are rejected — the daemon is expected to emit - raw UTF-8 bytes. -- Input is trusted (daemon is local, same user). ptyc does not sanitise - argv or env beyond format-level checks — if the daemon asks ptyc to - exec `rm`, ptyc execs `rm`. -- ptyc does not `setuid` or `setgid`. It runs as the invoking user. - -## Session persistence - -ptyc is stateless and one-shot. Session persistence (survive app -restart) is the **caller's** concern. Clide achieves it by spawning -ptyc with `tmux new-session -A -s -- ` for Claude panes; -tmux handles the persistence layer and ptyc just spawns tmux. See -`D-041` (Claude panes — one primary per repo, tmux-backed). diff --git a/ptyc/ptyc.c b/ptyc/ptyc.c deleted file mode 100644 index 48095ffb..00000000 --- a/ptyc/ptyc.c +++ /dev/null @@ -1,479 +0,0 @@ -/* - * ptyc — spawn a child under a PTY, hand the master fd back. - * - * Wire contract (documented in README.md): - * stdin : JSON request {"argv":[...],"cwd":"...","env":{...},"cols":N,"rows":N} - * stdout : JSON response {"ok":true,"pid":N} on success - * stderr : JSON diagnostic {"ok":false,"error":"...","errno":N} on failure - * fd 3 : unix-domain socket; master fd is sent over SCM_RIGHTS on success - * - * Exit codes: - * 0 success - * 1 bad request (JSON parse error, missing field, bad value) - * 2 syscall failed (fork/exec/pty/socket) - * - * Dependencies: libc only. POSIX APIs where available (posix_openpt, - * grantpt, unlockpt, ptsname). Single-threaded, one-shot, ~300 LOC. - */ - -#define _POSIX_C_SOURCE 200809L -#define _XOPEN_SOURCE 600 -#ifdef __APPLE__ -#define _DARWIN_C_SOURCE /* CMSG_SPACE / CMSG_LEN on macOS */ -#endif - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -/* POSIX exposes `environ` but requires an explicit declaration. We - * set it to NULL in the child to replace the inherited environment - * when the caller supplied one — `clearenv()` is GNU-only, not POSIX. */ -extern char **environ; - -/* Discard write() result without tripping warn_unused_result. We only - * call this on the exec-failure pipe in the child immediately before - * _exit(127); the parent either receives the bytes or notices EOF via - * the CLOEXEC pipe. Nothing useful to do with the return value. */ -static void report_errno(int fd, int e) { - ssize_t r = write(fd, &e, sizeof(e)); - (void)r; -} - -#define MAX_ARGV 64 -#define MAX_ENV 256 -#define MAX_INPUT (64 * 1024) - -/* -------------------------------------------------------------------- */ -/* error reporting */ -/* -------------------------------------------------------------------- */ - -static void die_bad_request(const char *msg) { - fprintf(stderr, "{\"ok\":false,\"error\":\"%s\",\"errno\":0}\n", msg); - exit(1); -} - -static void die_syscall(const char *msg) { - int e = errno; - /* Avoid quoting edge cases: strerror results don't contain quotes on - * any platform we care about; if this ever bites us we'll escape. */ - fprintf(stderr, "{\"ok\":false,\"error\":\"%s: %s\",\"errno\":%d}\n", msg, - strerror(e), e); - exit(2); -} - -/* -------------------------------------------------------------------- */ -/* minimal JSON parser */ -/* */ -/* Scoped to exactly the shape we accept. String escapes supported for */ -/* the subset we emit (\" \\ \n \r \t \b \f \/ and \uXXXX for ASCII). */ -/* Surrogate pairs, nested arrays, and non-string numbers-as-keys are */ -/* not supported — the daemon never emits them. */ -/* -------------------------------------------------------------------- */ - -typedef struct { - const char *src; - size_t len; - size_t pos; -} Parser; - -static void p_skip_ws(Parser *p) { - while (p->pos < p->len) { - char c = p->src[p->pos]; - if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { - p->pos++; - } else { - break; - } - } -} - -static int p_peek(Parser *p) { - p_skip_ws(p); - return p->pos < p->len ? (unsigned char)p->src[p->pos] : -1; -} - -static int p_expect(Parser *p, char c) { - if (p_peek(p) != (unsigned char)c) return 0; - p->pos++; - return 1; -} - -/* Parse a JSON string into a freshly-allocated NUL-terminated buffer. */ -static char *p_string(Parser *p) { - if (p_peek(p) != '"') return NULL; - p->pos++; - size_t start = p->pos; - /* First pass: find end and compute output length. */ - size_t out_len = 0; - while (p->pos < p->len && p->src[p->pos] != '"') { - if (p->src[p->pos] == '\\') { - if (p->pos + 1 >= p->len) return NULL; - char esc = p->src[p->pos + 1]; - if (esc == 'u') { - if (p->pos + 5 >= p->len) return NULL; - /* We only accept ASCII in \uXXXX. */ - for (int i = 2; i < 6; i++) { - if (!isxdigit((unsigned char)p->src[p->pos + i])) return NULL; - } - p->pos += 6; - } else { - p->pos += 2; - } - out_len++; - } else { - p->pos++; - out_len++; - } - } - if (p->pos >= p->len || p->src[p->pos] != '"') return NULL; - size_t end = p->pos; - p->pos++; /* consume closing quote */ - - char *out = malloc(out_len + 1); - if (!out) return NULL; - size_t j = 0; - for (size_t i = start; i < end;) { - if (p->src[i] == '\\') { - char esc = p->src[i + 1]; - switch (esc) { - case '"': out[j++] = '"'; i += 2; break; - case '\\': out[j++] = '\\'; i += 2; break; - case '/': out[j++] = '/'; i += 2; break; - case 'b': out[j++] = '\b'; i += 2; break; - case 'f': out[j++] = '\f'; i += 2; break; - case 'n': out[j++] = '\n'; i += 2; break; - case 'r': out[j++] = '\r'; i += 2; break; - case 't': out[j++] = '\t'; i += 2; break; - case 'u': { - unsigned int cp = 0; - for (int k = 0; k < 4; k++) { - char h = p->src[i + 2 + k]; - cp <<= 4; - if (h >= '0' && h <= '9') cp |= (unsigned)(h - '0'); - else if (h >= 'a' && h <= 'f') cp |= (unsigned)(h - 'a' + 10); - else if (h >= 'A' && h <= 'F') cp |= (unsigned)(h - 'A' + 10); - } - /* ASCII-range only. Anything else is a request-format bug. */ - if (cp > 0x7f) { free(out); return NULL; } - out[j++] = (char)cp; - i += 6; - break; - } - default: free(out); return NULL; - } - } else { - out[j++] = p->src[i++]; - } - } - out[j] = '\0'; - return out; -} - -static int p_int(Parser *p, long *out) { - p_skip_ws(p); - size_t start = p->pos; - if (p->pos < p->len && (p->src[p->pos] == '-' || p->src[p->pos] == '+')) - p->pos++; - int digits = 0; - while (p->pos < p->len && isdigit((unsigned char)p->src[p->pos])) { - p->pos++; - digits++; - } - if (!digits) { - p->pos = start; - return 0; - } - char buf[32]; - size_t n = p->pos - start; - if (n >= sizeof(buf)) return 0; - memcpy(buf, p->src + start, n); - buf[n] = '\0'; - *out = strtol(buf, NULL, 10); - return 1; -} - -/* -------------------------------------------------------------------- */ -/* request */ -/* -------------------------------------------------------------------- */ - -typedef struct { - char *argv[MAX_ARGV + 1]; /* NULL-terminated */ - int argc; - char *cwd; /* optional, NULL means inherit */ - char *env[MAX_ENV + 1]; /* each "KEY=VAL" */ - int envc; - int cols; - int rows; -} Request; - -static void req_init(Request *r) { - memset(r, 0, sizeof(*r)); - r->cols = 80; - r->rows = 24; -} - -static void req_free(Request *r) { - for (int i = 0; i < r->argc; i++) free(r->argv[i]); - for (int i = 0; i < r->envc; i++) free(r->env[i]); - free(r->cwd); -} - -static void parse_argv(Parser *p, Request *r) { - if (!p_expect(p, '[')) die_bad_request("argv must be an array"); - if (p_peek(p) == ']') { p->pos++; return; } - for (;;) { - if (r->argc >= MAX_ARGV) die_bad_request("argv too long"); - char *s = p_string(p); - if (!s) die_bad_request("argv element must be a string"); - r->argv[r->argc++] = s; - if (p_expect(p, ',')) continue; - if (p_expect(p, ']')) break; - die_bad_request("malformed argv array"); - } -} - -static void parse_env(Parser *p, Request *r) { - if (!p_expect(p, '{')) die_bad_request("env must be an object"); - if (p_peek(p) == '}') { p->pos++; return; } - for (;;) { - if (r->envc >= MAX_ENV) die_bad_request("env too large"); - char *k = p_string(p); - if (!k) die_bad_request("env key must be a string"); - if (!p_expect(p, ':')) { free(k); die_bad_request("env missing ':'"); } - char *v = p_string(p); - if (!v) { free(k); die_bad_request("env value must be a string"); } - size_t kl = strlen(k), vl = strlen(v); - char *kv = malloc(kl + 1 + vl + 1); - if (!kv) { free(k); free(v); die_syscall("malloc"); } - memcpy(kv, k, kl); - kv[kl] = '='; - memcpy(kv + kl + 1, v, vl); - kv[kl + 1 + vl] = '\0'; - free(k); free(v); - r->env[r->envc++] = kv; - if (p_expect(p, ',')) continue; - if (p_expect(p, '}')) break; - die_bad_request("malformed env object"); - } -} - -static void parse_request(const char *src, size_t len, Request *r) { - Parser p = { .src = src, .len = len, .pos = 0 }; - if (!p_expect(&p, '{')) die_bad_request("top-level must be an object"); - if (p_peek(&p) == '}') { p.pos++; goto done; } - for (;;) { - char *key = p_string(&p); - if (!key) die_bad_request("key must be a string"); - if (!p_expect(&p, ':')) { free(key); die_bad_request("missing ':'"); } - if (strcmp(key, "argv") == 0) { - parse_argv(&p, r); - } else if (strcmp(key, "cwd") == 0) { - r->cwd = p_string(&p); - if (!r->cwd) { free(key); die_bad_request("cwd must be a string"); } - } else if (strcmp(key, "env") == 0) { - parse_env(&p, r); - } else if (strcmp(key, "cols") == 0) { - long v; if (!p_int(&p, &v)) { free(key); die_bad_request("cols must be an integer"); } - if (v < 1 || v > 65535) { free(key); die_bad_request("cols out of range"); } - r->cols = (int)v; - } else if (strcmp(key, "rows") == 0) { - long v; if (!p_int(&p, &v)) { free(key); die_bad_request("rows must be an integer"); } - if (v < 1 || v > 65535) { free(key); die_bad_request("rows out of range"); } - r->rows = (int)v; - } else { - free(key); - die_bad_request("unknown key"); - } - free(key); - if (p_expect(&p, ',')) continue; - if (p_expect(&p, '}')) break; - die_bad_request("malformed object"); - } -done: - if (r->argc == 0) die_bad_request("argv is required and non-empty"); - r->argv[r->argc] = NULL; - r->env[r->envc] = NULL; -} - -/* -------------------------------------------------------------------- */ -/* read stdin into a bounded buffer */ -/* -------------------------------------------------------------------- */ - -static char *slurp_stdin(size_t *out_len) { - char *buf = malloc(MAX_INPUT); - if (!buf) die_syscall("malloc"); - size_t n = 0; - while (n < MAX_INPUT) { - ssize_t r = read(0, buf + n, MAX_INPUT - n); - if (r == 0) break; - if (r < 0) { - if (errno == EINTR) continue; - die_syscall("read(stdin)"); - } - n += (size_t)r; - } - if (n == MAX_INPUT) die_bad_request("request too large"); - *out_len = n; - return buf; -} - -/* -------------------------------------------------------------------- */ -/* PTY open + spawn */ -/* -------------------------------------------------------------------- */ - -static void send_fd(int sock, int fd) { - /* sendmsg with SCM_RIGHTS; one byte payload so receiver knows to read. */ - char byte = 'x'; - struct iovec iov = { .iov_base = &byte, .iov_len = 1 }; - union { - struct cmsghdr hdr; - char buf[CMSG_SPACE(sizeof(int))]; - } cbuf; - memset(&cbuf, 0, sizeof(cbuf)); - - struct msghdr msg = {0}; - msg.msg_iov = &iov; - msg.msg_iovlen = 1; - msg.msg_control = cbuf.buf; - msg.msg_controllen = sizeof(cbuf.buf); - - struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); - cmsg->cmsg_level = SOL_SOCKET; - cmsg->cmsg_type = SCM_RIGHTS; - cmsg->cmsg_len = CMSG_LEN(sizeof(int)); - memcpy(CMSG_DATA(cmsg), &fd, sizeof(int)); - - for (;;) { - ssize_t r = sendmsg(sock, &msg, 0); - if (r < 0 && errno == EINTR) continue; - if (r < 0) die_syscall("sendmsg(fd)"); - break; - } -} - -int main(void) { - Request req; - req_init(&req); - - size_t in_len = 0; - char *in = slurp_stdin(&in_len); - parse_request(in, in_len, &req); - free(in); - - /* 1. open master */ - int master = posix_openpt(O_RDWR | O_NOCTTY); - if (master < 0) die_syscall("posix_openpt"); - if (grantpt(master) < 0) die_syscall("grantpt"); - if (unlockpt(master) < 0) die_syscall("unlockpt"); - - /* 2. open slave (ptsname is POSIX; we're single-threaded) */ - const char *slave_path = ptsname(master); - if (!slave_path) die_syscall("ptsname"); - int slave = open(slave_path, O_RDWR | O_NOCTTY); - if (slave < 0) die_syscall("open(slave)"); - - /* 3. apply window size */ - struct winsize ws = {0}; - ws.ws_col = (unsigned short)req.cols; - ws.ws_row = (unsigned short)req.rows; - if (ioctl(master, TIOCSWINSZ, &ws) < 0) die_syscall("ioctl(TIOCSWINSZ)"); - - /* 4. exec-failure-reporting pipe (CLOEXEC so it auto-closes on success) */ - int ef[2]; - if (pipe(ef) < 0) die_syscall("pipe"); - if (fcntl(ef[1], F_SETFD, FD_CLOEXEC) < 0) die_syscall("fcntl(FD_CLOEXEC)"); - - pid_t pid = fork(); - if (pid < 0) die_syscall("fork"); - - if (pid == 0) { - /* ---- child ---- */ - close(master); - close(ef[0]); - - if (setsid() < 0) { report_errno(ef[1], errno); _exit(127); } -#ifdef TIOCSCTTY - if (ioctl(slave, TIOCSCTTY, 0) < 0) { report_errno(ef[1], errno); _exit(127); } -#endif - if (dup2(slave, 0) < 0 || dup2(slave, 1) < 0 || dup2(slave, 2) < 0) { - report_errno(ef[1], errno); - _exit(127); - } - if (slave > 2) close(slave); - - if (req.cwd && chdir(req.cwd) < 0) { report_errno(ef[1], errno); _exit(127); } - - /* Replace the environment if the caller supplied one; otherwise - * inherit. Daemon is expected to build the env it wants — this is - * not a "merge" API. */ - if (req.envc > 0) { - environ = NULL; - for (int i = 0; i < req.envc; i++) { - if (putenv(req.env[i]) != 0) { report_errno(ef[1], errno); _exit(127); } - } - } - - execvp(req.argv[0], req.argv); - /* execvp returned → failure */ - report_errno(ef[1], errno); - _exit(127); - } - - /* ---- parent ---- */ - close(slave); - close(ef[1]); - - int child_errno = 0; - ssize_t rr; - for (;;) { - rr = read(ef[0], &child_errno, sizeof(child_errno)); - if (rr < 0 && errno == EINTR) continue; - break; - } - close(ef[0]); - - if (rr == (ssize_t)sizeof(child_errno)) { - /* exec failed in child; reap it so we don't leak a zombie. */ - int st; - (void)waitpid(pid, &st, 0); - close(master); - errno = child_errno; - die_syscall("execvp"); - } - /* rr == 0: pipe closed via CLOEXEC on successful exec. */ - - /* Hand the master fd back to the parent caller over a unix socket. - * Default fd is 3; callers that can't reliably place the socket at - * fd 3 (e.g. Python's subprocess with stdout=PIPE shifts pipe fds - * around fd 3) can override via PTYC_SOCK_FD. */ - int sock_fd = 3; - const char *sock_env = getenv("PTYC_SOCK_FD"); - if (sock_env && *sock_env) { - char *endp = NULL; - long v = strtol(sock_env, &endp, 10); - if (!endp || *endp != '\0' || v < 0 || v > 65535) - die_bad_request("PTYC_SOCK_FD must be a non-negative integer"); - sock_fd = (int)v; - } - send_fd(sock_fd, master); - close(master); - - /* Emit success response on stdout and exit. */ - printf("{\"ok\":true,\"pid\":%ld}\n", (long)pid); - fflush(stdout); - - req_free(&req); - return 0; -} diff --git a/ptyc/test_ptyc.sh b/ptyc/test_ptyc.sh deleted file mode 100755 index 56529bd5..00000000 --- a/ptyc/test_ptyc.sh +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/env bash -# Smoke test for ptyc. Uses python3 for the SCM_RIGHTS fd receive dance. -set -euo pipefail - -HERE="$(cd "$(dirname "$0")" && pwd)" -BIN="$HERE/bin/ptyc" - -if [[ ! -x "$BIN" ]]; then - echo "test: bin/ptyc not built — run 'make' first" >&2 - exit 2 -fi - -python3 - "$BIN" <<'PY' -import errno -import json -import os -import socket -import subprocess -import sys -import time - -ptyc = sys.argv[1] - - -def spawn(req): - """Launch ptyc, pipe the request in, receive fd + response.""" - sock_parent, sock_child = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) - child_fd = sock_child.fileno() - env = {**os.environ, "PTYC_SOCK_FD": str(child_fd)} - try: - p = subprocess.Popen( - [ptyc], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - pass_fds=(child_fd,), - env=env, - ) - finally: - sock_child.close() - - p.stdin.write(json.dumps(req).encode()) - p.stdin.close() - - fd = None - try: - msg, ancdata, _flags, _addr = sock_parent.recvmsg(1, socket.CMSG_SPACE(4)) - for cmsg_level, cmsg_type, cmsg_data in ancdata: - if cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS: - fd = int.from_bytes(cmsg_data[:4], "little") - break - except OSError: - pass - sock_parent.close() - - stdout = p.stdout.read().decode() - stderr = p.stderr.read().decode() - code = p.wait() - return code, stdout, stderr, fd - - -def expect_ok(req, reads_substr=None): - code, out, err, fd = spawn(req) - assert code == 0, f"exit={code}, stderr={err!r}" - j = json.loads(out) - assert j["ok"] is True, out - assert j["pid"] > 0, out - assert fd is not None and fd >= 0, "no fd received" - pid = j["pid"] - try: - if reads_substr is not None: - chunks = [] - deadline = time.time() + 5.0 - while time.time() < deadline: - try: - data = os.read(fd, 4096) - except OSError as e: - if e.errno in (errno.EIO,): # child exited, PTY EOF on Linux - break - raise - if not data: - break - chunks.append(data.decode(errors="replace")) - if reads_substr in "".join(chunks): - break - got = "".join(chunks) - assert reads_substr in got, f"expected {reads_substr!r} in {got!r}" - finally: - os.close(fd) - # Child should exit on its own after producing its output for an - # `echo`; give it a moment, then reap. - try: - for _ in range(20): - rpid, _ = os.waitpid(pid, os.WNOHANG) - if rpid == pid: - break - time.sleep(0.05) - else: - os.kill(pid, 9) - os.waitpid(pid, 0) - except ChildProcessError: - pass - - -def expect_err(req, match_fragment): - code, out, err, fd = spawn(req) - assert code != 0, f"expected failure, got ok: {out!r}" - assert fd is None, "error path must not send an fd" - j = json.loads(err) - assert j["ok"] is False, err - assert match_fragment in j["error"], f"{match_fragment!r} not in {j['error']!r}" - - -# 1. Happy path: echo prints and exits cleanly. -expect_ok({"argv": ["/bin/echo", "hello-ptyc"]}, reads_substr="hello-ptyc") - -# 2. env replacement works — child sees exactly the keys we pass. -expect_ok( - {"argv": ["/usr/bin/env"], "env": {"FOO": "bar", "PATH": "/usr/bin:/bin"}}, - reads_substr="FOO=bar", -) - -# 3. cwd respected. -expect_ok({"argv": ["/bin/sh", "-c", "pwd"], "cwd": "/tmp"}, reads_substr="/tmp") - -# 4. window size propagates (stty reports it). -expect_ok( - {"argv": ["/bin/sh", "-c", "stty size"], "cols": 132, "rows": 42}, - reads_substr="42 132", -) - -# 5. Bad request: missing argv. -expect_err({}, "argv is required") - -# 6. Bad request: argv not an array. -expect_err({"argv": "bash"}, "argv must be an array") - -# 7. exec failure reported on error channel. -expect_err({"argv": ["/does/not/exist/nope"]}, "execvp") - -# 8. Unknown key rejected. -expect_err({"argv": ["true"], "wat": 1}, "unknown key") - -print("ptyc: all smoke tests passed") -PY diff --git a/test/cli/shortcuts_test.dart b/test/cli/shortcuts_test.dart deleted file mode 100644 index e3d1190f..00000000 --- a/test/cli/shortcuts_test.dart +++ /dev/null @@ -1,158 +0,0 @@ -/// End-to-end tests for the tier-2 CLI shortcuts. -/// -/// Each test launches `bin/clide --daemon` as a subprocess, exercises -/// a shortcut (`open`, `active`, `insert`, `save`, `tail`), and -/// asserts the JSON response shape. Requires a built `bin/clide` -/// binary — `ci/test_core.sh` runs `make build` first when needed, -/// or here we build on demand. -library; - -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:test/test.dart'; - -const _socketEnv = 'CLIDE_SOCKET_PATH'; - -void main() { - late Directory sandbox; - late Process daemon; - late String socketPath; - late String clideBin; - - setUpAll(() async { - final candidate = File('bin/clide'); - if (!candidate.existsSync()) { - final built = await Process.run('make', const ['build']); - if (built.exitCode != 0) { - throw StateError('make build failed: ${built.stderr}'); - } - } - clideBin = candidate.absolute.path; - }); - - setUp(() async { - sandbox = await Directory.systemTemp.createTemp('clide-cli-t2-'); - await File('${sandbox.path}/doc.md').writeAsString('alpha beta'); - // Each test gets its own socket path so concurrent test runs don't - // collide. Passed through the daemon via env. - socketPath = '${sandbox.path}/daemon.sock'; - daemon = await Process.start( - clideBin, - const ['--daemon'], - workingDirectory: sandbox.path, - environment: { - ...Platform.environment, - _socketEnv: socketPath, - }, - ); - // Wait for the "listening" line on stderr so we know it's ready. - final ready = Completer(); - daemon.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen((line) { - if (!ready.isCompleted && line.contains('listening')) { - ready.complete(); - } - }); - await ready.future.timeout(const Duration(seconds: 5)); - }); - - tearDown(() async { - daemon.kill(ProcessSignal.sigterm); - await daemon.exitCode.timeout(const Duration(seconds: 3), onTimeout: () { - daemon.kill(ProcessSignal.sigkill); - return -1; - }); - if (sandbox.existsSync()) sandbox.deleteSync(recursive: true); - }); - - Future> run(List args) async { - final r = await Process.run( - clideBin, - args, - workingDirectory: sandbox.path, - environment: { - ...Platform.environment, - _socketEnv: socketPath, - }, - ); - expect(r.exitCode, 0, reason: 'stderr: ${r.stderr}'); - return jsonDecode(r.stdout.toString().trim()) as Map; - } - - test('clide open returns buffer metadata', () async { - final r = await run(['open', 'doc.md']); - expect(r['id'], startsWith('b_')); - expect(r['path'], 'doc.md'); - }); - - test('clide active reflects the most recent open', () async { - await run(['open', 'doc.md']); - final r = await run(['active']); - final active = r['active']! as Map; - expect(active['path'], 'doc.md'); - }); - - test('clide insert + clide active round-trip', () async { - await run(['open', 'doc.md']); - await run(['insert', 'hello ']); - final r = await run(['active']); - final active = r['active']! as Map; - expect(active['dirty'], isTrue); - expect((active['length'] as num).toInt(), greaterThan('alpha beta'.length)); - }); - - test('clide save clears dirty + writes to disk', () async { - await run(['open', 'doc.md']); - await run(['insert', 'X ']); - await run(['save']); - final active = (await run(['active']))['active']! as Map; - expect(active['dirty'], isFalse); - final disk = await File('${sandbox.path}/doc.md').readAsString(); - expect(disk.startsWith('X '), isTrue); - }); - - test('clide tail --events streams editor.* events', () async { - // Start a tail subscriber. - final tail = await Process.start( - clideBin, - const ['tail', '--events', '--filter', 'editor'], - workingDirectory: sandbox.path, - environment: { - ...Platform.environment, - _socketEnv: socketPath, - }, - ); - - final received = >[]; - final sub = tail.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen((line) { - if (line.isEmpty) return; - received.add(jsonDecode(line) as Map); - }); - - // Give the subscriber a beat to connect. - await Future.delayed(const Duration(milliseconds: 200)); - - await run(['open', 'doc.md']); - await run(['insert', 'T ']); - - // Wait up to 2s for events. - for (var i = 0; i < 20 && received.length < 3; i++) { - await Future.delayed(const Duration(milliseconds: 100)); - } - - tail.kill(ProcessSignal.sigint); - await tail.exitCode.timeout(const Duration(seconds: 2), onTimeout: () { - tail.kill(ProcessSignal.sigkill); - return -1; - }); - await sub.cancel(); - - final kinds = received.map((e) => e['kind']).toList(); - expect(kinds, containsAll(['editor.opened', 'editor.edited'])); - // Confirm filter actually filtered — no pane events made it in. - for (final e in received) { - expect(e['subsystem'], 'editor'); - } - }); -} diff --git a/test/daemon/in_process_test.dart b/test/daemon/in_process_test.dart deleted file mode 100644 index 4159d45f..00000000 --- a/test/daemon/in_process_test.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:clide/clide.dart'; -import 'package:test/test.dart'; - -void main() { - group('DaemonServer (in-process)', () { - late DaemonServer server; - late DaemonDispatcher dispatcher; - late String socketPath; - - setUp(() async { - final tmp = await Directory.systemTemp.createTemp('clide_daemon_'); - socketPath = '${tmp.path}/daemon.sock'; - dispatcher = DaemonDispatcher(); - server = DaemonServer( - socketPath: socketPath, - dispatch: dispatcher.dispatch, - ); - await server.start(); - }); - - tearDown(() async { - await server.stop(); - }); - - test('ping round-trips with pong=true', () async { - final resp = await _send( - socketPath, - IpcRequest(id: '1', cmd: 'ping').encode(), - ); - final parsed = IpcMessage.decode(resp) as IpcResponse; - expect(parsed.ok, true); - expect(parsed.id, '1'); - expect(parsed.data['pong'], true); - expect(parsed.data['ts'], isA()); - expect(parsed.data['version'], isA()); - }); - - test('version returns current clideVersion', () async { - final resp = await _send( - socketPath, - IpcRequest(id: 'v', cmd: 'version').encode(), - ); - final parsed = IpcMessage.decode(resp) as IpcResponse; - expect(parsed.ok, true); - expect(parsed.data['version'], clideVersion); - }); - - test('unknown command returns NotFound (exit code 3)', () async { - final resp = await _send( - socketPath, - IpcRequest(id: 'x', cmd: 'this.does.not.exist').encode(), - ); - final parsed = IpcMessage.decode(resp) as IpcResponse; - expect(parsed.ok, false); - expect(parsed.error!.code, IpcExitCode.notFound); - expect(parsed.error!.kind, IpcErrorKind.notFound); - expect(parsed.error!.message, contains('this.does.not.exist')); - }); - - test('multiple concurrent requests on one connection', () async { - final socket = await Socket.connect( - InternetAddress(socketPath, type: InternetAddressType.unix), - 0, - ); - for (var i = 0; i < 5; i++) { - socket.writeln(IpcRequest(id: '$i', cmd: 'ping').encode()); - } - final lines = []; - final done = Completer(); - final sub = socket.cast>().transform(utf8.decoder).transform(const LineSplitter()).listen((line) { - lines.add(line); - if (lines.length == 5) done.complete(); - }); - await done.future.timeout(const Duration(seconds: 2)); - await sub.cancel(); - await socket.close(); - final ids = lines.map((l) => (IpcMessage.decode(l) as IpcResponse).id).toSet(); - expect(ids, {'0', '1', '2', '3', '4'}); - }); - - test('custom handler plugs into dispatcher', () async { - dispatcher.register('test.custom', (req) async { - return IpcResponse.ok(id: req.id, data: {'echo': req.args}); - }); - final resp = await _send( - socketPath, - IpcRequest( - id: 'c', - cmd: 'test.custom', - args: const {'x': 1}, - ).encode(), - ); - final parsed = IpcMessage.decode(resp) as IpcResponse; - expect(parsed.ok, true); - expect(parsed.data['echo'], {'x': 1}); - }); - }); -} - -Future _send(String socketPath, String line) async { - final socket = await Socket.connect( - InternetAddress(socketPath, type: InternetAddressType.unix), - 0, - ); - socket.writeln(line); - final resp = await socket.cast>().transform(utf8.decoder).transform(const LineSplitter()).first.timeout(const Duration(seconds: 2)); - await socket.close(); - return resp; -} diff --git a/test/daemon/pane_commands_test.dart b/test/daemon/pane_commands_test.dart index 9c98c081..f7275906 100644 --- a/test/daemon/pane_commands_test.dart +++ b/test/daemon/pane_commands_test.dart @@ -9,7 +9,6 @@ import 'dart:convert'; import 'dart:io'; import 'package:clide/clide.dart'; -import 'package:clide/kernel/src/toolchain.dart'; import 'package:clide/src/daemon/pane_commands.dart'; import 'package:clide/src/panes/registry.dart'; import 'package:test/test.dart'; @@ -17,9 +16,6 @@ import 'package:test/test.dart'; void main() { if (!Platform.isLinux && !Platform.isMacOS) return; - final toolchain = Toolchain(); - toolchain.applyResolved(Toolchain.resolvePaths(workspaceRoot: Directory.current.path)); - group('pane.* dispatch', () { late DaemonDispatcher dispatcher; late PaneRegistry registry; @@ -48,7 +44,6 @@ void main() { final r = await call('pane.spawn', { 'argv': const ['/bin/sh', '-c', 'sleep 0.1'], 'kind': 'terminal', - 'ptyc_path': toolchain.ptyc, }); expect(r.ok, isTrue, reason: r.error?.message); expect(r.data['id'], startsWith('p_')); @@ -58,12 +53,10 @@ void main() { test('pane.list shows spawned panes', () async { await call('pane.spawn', { 'argv': const ['/bin/cat'], - 'ptyc_path': toolchain.ptyc, }); await call('pane.spawn', { 'argv': const ['/bin/cat'], 'kind': 'claude', - 'ptyc_path': toolchain.ptyc, }); final r = await call('pane.list', const {}); final panes = (r.data['panes'] as List).cast(); @@ -74,7 +67,6 @@ void main() { test('pane.write accepts text or bytes_b64', () async { final spawn = await call('pane.spawn', { 'argv': const ['/bin/cat'], - 'ptyc_path': toolchain.ptyc, }); final id = spawn.data['id']! as String; @@ -98,7 +90,6 @@ void main() { test('pane.resize + pane.close + pane.focus round-trip', () async { final spawn = await call('pane.spawn', { 'argv': const ['/bin/cat'], - 'ptyc_path': toolchain.ptyc, }); final id = spawn.data['id']! as String; diff --git a/test/daemon/subprocess_test.dart b/test/daemon/subprocess_test.dart deleted file mode 100644 index 6543f2d9..00000000 --- a/test/daemon/subprocess_test.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:clide/clide.dart'; -import 'package:test/test.dart'; - -/// Subprocess-level daemon smoke. Only runs if `bin/clide` has been -/// built (the test skips itself otherwise). This is the release-gate -/// suite — catches signal-handling, socket-unlink, and version-stamp -/// regressions that the in-process test masks. -void main() { - final binary = File('bin/clide'); - - group('bin/clide --daemon (subprocess)', () { - setUpAll(() { - if (!binary.existsSync()) { - markTestSkipped('bin/clide not built; run `make build` first to enable this suite'); - } - }); - - test('starts, responds to ping, exits cleanly on SIGTERM', () async { - if (!binary.existsSync()) return; - - // Use a fresh socket under a unique temp path so parallel test - // runs don't collide. The daemon resolves its socket path from - // XDG_RUNTIME_DIR + USER (see defaultSocketPath()). - final tmp = await Directory.systemTemp.createTemp('clide_sub_'); - final env = Map.from(Platform.environment) - ..['XDG_RUNTIME_DIR'] = tmp.path - ..['USER'] = 'daemon'; - final socketPath = '${tmp.path}/clide-daemon.sock'; - - final process = await Process.start( - binary.absolute.path, - ['--daemon'], - environment: env, - ); - - // Wait for "listening on ..." on stderr before connecting. - final ready = Completer(); - final stderrLines = []; - final sub = process.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen((line) { - stderrLines.add(line); - if (line.contains('listening')) ready.complete(); - }); - - try { - await ready.future.timeout(const Duration(seconds: 3)); - - // Connect and ping - final sock = await Socket.connect( - InternetAddress(socketPath, type: InternetAddressType.unix), - 0, - ).timeout(const Duration(seconds: 3)); - sock.writeln(IpcRequest(id: '1', cmd: 'ping').encode()); - final line = await sock.cast>().transform(utf8.decoder).transform(const LineSplitter()).first.timeout(const Duration(seconds: 3)); - await sock.close(); - final resp = IpcMessage.decode(line) as IpcResponse; - expect(resp.ok, true); - expect(resp.data['pong'], true); - - // Clean shutdown - process.kill(ProcessSignal.sigterm); - final exitCode = await process.exitCode.timeout(const Duration(seconds: 3)); - expect(exitCode, 0); - - // Socket file should be unlinked - expect(await File(socketPath).exists(), false); - } finally { - await sub.cancel(); - try { - process.kill(ProcessSignal.sigkill); - } catch (_) {} - try { - await tmp.delete(recursive: true); - } catch (_) {} - } - }, timeout: const Timeout(Duration(seconds: 15))); - }); -} diff --git a/test/goldens/flutter_test_config.dart b/test/goldens/flutter_test_config.dart new file mode 100644 index 00000000..d1e3da70 --- /dev/null +++ b/test/goldens/flutter_test_config.dart @@ -0,0 +1,12 @@ +import 'dart:async'; + +import 'package:alchemist/alchemist.dart'; + +import '../helpers/golden_harness.dart'; + +Future testExecutable(FutureOr Function() testMain) async { + return AlchemistConfig.runWithConfig( + config: clideGoldenConfig(), + run: testMain, + ); +} diff --git a/test/goldens/goldens/ci/clide_button.png b/test/goldens/goldens/ci/clide_button.png deleted file mode 100644 index 7c8b9051b40db11c530e8ab91d650eba36fb6c75..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2391 zcmchYX;f2Z8pkgL!LpQ6kP3vQQVW&EL1NjYAOe;`6Ce;EARv3ivdB`CfPm1lM=fI* z*`gqXgdIT;h@r@$$d)n4mW7%k7}gMCkhM4cGWJY6#uKM z!B7FFp_?24*xt7{0*$E;#s3!K%#Z|t>qh^FgStzhyL}7|K%H`kt(!LEv|UH+y~7R2 zqjZ=GRZ^3=cU8euU(7~>Q8gdEAOQf63DpMxK*zWI-wseyMfv4c?H^FF2A|%eGH?;M zo4s>bJnuXLqOWVAsth7SG2QYtT;2Puit9`!jkzvgIw_s0g^v~OF_3{D_r>d>8um_2 z3sEj%Tt*ytn+kojf?g78Bdiaox+ltLH2E*}-Qtph>YgE}nbOlxMefq;K69JNEY?jD zax@LnnzTH@&+pX2BebzcD1_r}Yx|CZZ+3#_mp5Pf(z2hH6I`I`UFxK3C0WN=RIQq2 zIL`t$*1uA@`P?cc+bq|gL6rghh&(rVb(eo$fLtYLpcgl|L-g$mujtA$NtqQgyOe)x zk9C!>$h$wyf3RoG_gEbPgFo+VlG~u|qujzAE1+7vFfbV4j%@~zN!@liPipbXrT#ok z7!ys>F7PSEN<$klr<&vSg$GZpxz7Fcw>O)vQ~4;K5{!)=zqWGm?^tQWUOJ>37sDMm zGHrsemI&lv!gW7-JKvt{AqU0Ml+gXcm|6b-n^LSKGjU{(E8_oCgNCq9_uXJ-BKmJ_ z!>h_9^4Dq%_VEo5Uvn9z?G8l02YOf57Uq$osfrl|?^4u1t@d9&m>c%n*xGuv8z32@ zVIIeMB3=wr;Po&GwHmuB11tK2xEy>unk=-c*ry+Ti_dgZ_2j9=bfftL;2h~b?v8BT zS6=6jgs2D@x)EK&*uyvnGoifqmrcGbjqo-zZhn(uNd0P%b>6+1kAmAQ30PTZyVrLd zJn{W~O<@vfD!_^m+=29zzM9TxQkkUOOmW+b&O+n}&`lNs#J(x7*cwTFI?AFlXL*g9zye}uxHF(EEj zY1yA?&52@^b_Sue9;|TY9rVzqd|OjPg?;sc3nvGkB({Vl3_XHavFjo{-3reJp;-l3 zS;M=+O9gzYLa{<3Kvu&4Gi!bq>&?0;{f=}w zdQDr+pRMZ#;%_qj(lEw=FAZ4kvfcw2-`zh&y+xmLIu^wDbSP9c*EW zYAE$k-9M_z&OnB9{PS?y+0;F*czy?00(GC~QqALbJZclR$f7aZV($48*2BZ|AdfoN zjA_a^9L|I|iX6Rj9$kkrG&+YLe`U+~F~2gBmjCmbyPvv$?$kvB_I(8p zPtRJm2_8ew?GOd>EY=?8_)`xZhbG&OT{;t4dZokyn*tJuft>9g7k=VcapU$}$4dbw z&#}{O0^a%faS}8=(o4$7%wr-!zJaQ|Of^Fy(!N(I@X^$>q%y1`+FRi^pWd?+%9{rY z4o_H-&t6aCUp5m;c~8c_{8)1}`ot~!NJ|%Mi0X^Xa)4b>Y@<5+i8k<~(p$7Ot=n4Kd}Y}N>Esr3EPSK{SO`9zX=l9 zd19@orwlG6cG53Ac<&b$*bW<5YBePh`9PI7pA{}R_q0n!@GNU za|QIseT%K#xrjbPAyhpAwGqU`>G0U;V!wNGlFs8Uu<6QpP{aWz2e^H`UEtMU0e}=K A%m4rY diff --git a/test/goldens/goldens/ci/clide_icon.png b/test/goldens/goldens/ci/clide_icon.png deleted file mode 100644 index ed6cf4d237c30c10015b5663c7222d65f7b6689f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5013 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 CPJlT8 diff --git a/test/goldens/goldens/ci/clide_tab_bar.png b/test/goldens/goldens/ci/clide_tab_bar.png deleted file mode 100644 index 28ea1f368050bae24fe4bb155075a0b021c806ee..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 809 zcmeAS@N?(olHy`uVBq!ia0y~yU~~YoOE}noV7lz-;uumf=k48%S(5`Ljz66IVP$4}0Bh`;0P%%Qvy?;^FA%Z%qO^x) z;q?pN0Uy|Q?Y1b@5RsK(>D{q&XLr=Wqg}m=lR2N9=KN`Ye$M+EeZ@Z&iicmu?A~Rj z$56b${?6=|zjqneN*|CBbWoVaz_gJ?phLC6L5GngiBmzqo1rm+iQ|Y6ky2~Yu5CA9 zJi{%GUFVu}U!}{BZEg3s`das@9OI&!aMQz#Uox-1zb7|+66=QDzqi!L)MmW>)4Tio zsZ&S2*Uq)~b#G^1J8%Eiy_aA8{Jh}pdjX&YK5)&XxccmzzgtO7V2|rPYZ~ z&zv_sZomIrib6wVWL&JSZ~fad=YI;bkRNo=0AKx*|9jSXhJaRH?9mXG{`cG8!ncY+ zbLafgTfThx*5fm{_udP7E69<;43Dw1OZang-)}KK$t1D2KD@8KCjaf9^z-pk4kj$# z`g!@wkBfnUIsb#Tjg`#`+e3#o!-ImleD>FVdQ&MBb@0OM6J AIsgCw diff --git a/test/goldens/goldens/macos/clide_button.png b/test/goldens/goldens/macos/clide_button.png new file mode 100644 index 0000000000000000000000000000000000000000..b86dfaf817b4ddbb458384ee496255b175ce9af2 GIT binary patch literal 17735 zcmbWf1yoy6xaUg^N`Yd9;q0^d8e z0>ywI&mF|&)L#N$o-a*;fxn+PsLF_=6b+JXqoBM)k&_ftcTL%AbV<;ciQT$IZU+qV z{RPeUrh`W8VN76S#S)_yns$^XZu1f&RF=~X4^9^fP6srW5#JwA{H+w5vflF=L;cB2 z8LP6a{|xe_c@8QcMtTh%2$QRz>8RFwFM<_jbDZ}zD)T4vbSv|!)O}8$aX)RPdOw5u zV&3Pg>x{rq)d#=RH$F!nrs+l+2Ed>jCsFP}gC@wF7UOX(f(t3m{=DFvN!G&aa6Sog<1Hey?{*|h*Ac?gzsw*U z@8^!5iQgW=Ps3ed3wU7d>_Y4Yck z?9$3B`Lz*}hxrLV2sgXi&Rz4J+pk_#D4jh>5K+^!x6gl=A^1)U`W(g$rDe~MRJo_% zszya}v!h045Eit89LPi9@$Awn%Y}7#jfVvNU(sMYGR%5G;2D;FQPPm6`-E0*8kN_& z2u;EDw^`v05Nh#^UAO+P0-998DH?&*_Vn@w`wfiZl;2TA8RxkzbwSwamz3g^*3bJy z5xHF;FIre!s=>qEL=RjL@BX%w>b~dUTZ4vH#+qi%Iylf{*u&6HkE7}*3=|HYS?_6T zlN;iGR!HM+(1CeL1&&=|K>pCJv2Nw4KWB4&)953j>4-yHk5}l}SKy}dlNdt1a83pf zRVZnjCu;95J!6ws=4wi^{3YnNy-nRnGaNW=5MEy>qi!6 zC-J;O^Uh@8aH7lsSIZ&^#-e0(L$lwpu9v5-y{sLWRmMe z-!E(?2tM#>a84fUImQxfOHlF#uzQlpooBu&Xsw65l?_#k;oHfpJ;QCZp}bkbEts|~ z6ES2rO@`<9M=X_Jz9ndRb@>gG3y;L&Ik4hwm`>jU>q`rtH_h^7+tax3U~k={MG3j} z%n$(PID!_r1gwP3fRo&|BQ#waYT~U7pO9-6m`1NF>L-r#%fgR zVW{vlTk(zK-J-G6ZxgL=Qn#s(R6-^;*V761uUznj&l*k03!Kqd-5Kn!Nhx_h>tF&Z2nejkj7^w-EO!xkBr9=Iltbp z%3FtLrubO_`x~&>Pps8(?Dlv_=qf|~*U5?z{AMvqK|f%!${A(P8U#)Gy^da%6}#^F zS>c-B@R^l71J0dg7JeRde&~Rfx@jyx`q-p)4jU!VbR6c=#z!V)xwekx6co$tb5xUX z&h6j5g|vr8^48JTkBv}`a@c6IvQA~VtjZL00D<>~pRU;%5 zcL95fQYTd}>lK5I++qXf18cm_BK&WMF{HE-C6s+OGNEC*8J9eNU0)=<#abL>BPsN~5cnEC_K2V7Bv&j1nyopV~c zX4wE5g+F*c!2@^#YzkWUyT^=6O55<{g=$I=_G*43#ZXyaise082>g!ReQ3drn1^#N zoOoM_6iY>B@62C$mcXV%&5i*DSd9=DL8*-0YxZYp9U5#^QfK3bsqupLWQC9*2b;|? z6cd{pS^F3he4%q0@HnR{xk0?-4j40|$d>b#%UVeC3e-$qE!VOp^ zjyX*fQG^2dHW2x1%gubyDM?(l0k9$7vf`J>QS`${LbDoDM*gi&_I>Z{_vi;5cznjm zE9vSC-%Xx;D4py^=sqq~2>zR{$wuV9qo(KhJ+sH|A|H7#_k0h78g`_9wPTyifeS%A zOlvnrXvW;Z*%MXn_=go9zil-qf8-f8ICh|R5AcY@g<%OlG|yV8Kj^HRO!yGV4ak#( zqOnP&axUa!^BAED49i#7y_zPnpcy^GSkWJYP{8ou;_4QQaya7VdCqI>W3a(f8{yILAZw80o;sRM|XA0o-?2J5$5A z*eizj+`ryT)#Ux$?U}X3cxTiZ_;UY|E~IEvZs8Tr`D-bGFS8DkW=u2^Rw>_wA*Ao2G)|J}sv z*)*Dk>;7p`BRB_m)^Id~){D{F)~m)|lenz;4H9ZhFC&)wf!%Qb-1=F31)0_t6=<0% z#uOiQBLlxbct*MU$;Vo*BFhd?B~q!FcK)3v-sB))X3bZjzUIRS_5tcL^J`-p11ww85Lf1oQT%_!f?r%Vhk^kA(Oc-XAjZeHs( z%E?&kVsASA>&E!Yt5Z7!#mw=m7)Np|H#qK#om?3;e;E+lhxF*nm-M}3^L=S_B$$g_ zcHZII!b!JVC))=dx@(Q~t^*^CNJlCJfq^BA4&)F_@u6ahst(JOX~k>SIqlUIq}Lr9 z(Ik#b3`PvW99kCzQk~oGa~&O8m7lW*l8p4SCQTA$4F@HsaA+B>4_X6@2A(O>Nu&NV zrx1cuh5JmDSyx5#zHn8LWz$9ARQf0_HO<7K-sM{QMsc9sz6L^H8F=Fr!%LP+KET3ht7k`I5iV@0ie=5?sRY>>H_)|P zEpP%sPLH})Vw5mM2&!9wOIzK=5V_*yUS>*qUgkQG3umq>973?;&)&4u2@lu*5kyNBUV8|@r6By-ZW$XF--XE?Q-8! z)m3CZv*nKkBzAN0yS?0|XZG&h8W@_c_{h9oxBS5pQi*uiuYKQeh`o76bR z*9BetY!=zYN!bEPQqRkecX37?1++(ZrAO^k3Iwn5%ajXOb*#N%EAK~6Uv&+CDcC}( zB2F_F8C^1DTA60vDYSdsiWqr=&!<*ei(L_11C0S|ag!(pEq|K{%+!UgX}wA|p)AdOi~vTb`~M19WpXFx_4r7kiO-JGoD-DQhkMeH&U z=QP^Im5sp5vOB;*na~ROR5+RncfB_`c>`v6XxOj&KSb?Hr*kqU(OdIj zFtyl30ZxqzJAaCxnOy@I7Z69bo`1*W=G~=lSBemTtS)A(u>{dh4tAOFo!*#H0m;*- zEu|*aQa_t~^*axY;A)ZB(oI>$JnG3iwwo5CalcCKQn8BB{c>sHK{+kr!6m6t`_YA< zn3G;5cF{z>_MexyGs&CdBaT)d)Dk)OSQANkkI^JvFAbclAfARU-3nFirhEm zNR%bR#|67F1CNC+rNjA6$$MzBxJuBPLq;-9r^4jTC6nGUGTVbn;vyS4HC@iM zlIASqvFTmpjMb5swoYu-{IJMw(8KrU`#~1rZFtn!_dlxwg+f(5sY2ZmyfXct* zX;T8nTEb%yW(9hG!AZS82wwIc@X1AH!aI+hV(;bKwLMz+A85G1Rt!zyD z6tSlpl3 z%1OKQeYIcVcb~G7#DTwO-e7&nzOVWnVr|ts(p2Fh)b?jT)*e#H=waX*bd}D|E6pa_ z)iPP#*FcbCoK?B%iTr$O!S>V&DD83t@u!Y_drH$*7@A)qk~cq+0mTR>k#E0AT;mHM zZc&ZY#~&?w0HGf+=Q(f+gNcgp!nzhdken_{TrTC-OwU-3pOg9G$9e?X2~2!_Mphjq z=tk5YZY=!3@RTLHJ&|wUVNS7Jvgyxo=x#{$;{=~&@$u$|+Dhe@XS~li_Zw-GqtnlQ z+Ayo?({FuAl{}lO%eQhD1&|n?Qh=YejwNxoN0FukNm0Br`qz^CpDLlMXB%)J_ z8q*aO2XUd_OX;_0D_-D8lThKImss(b}Z- z_Q1Ej&#_k622i66SN-hNbS zIw)+ylN+E~xCq#F);!f(NvuidYIo+>i3w@W>3ipK(vx?~7IN@*j`$Z$?Ps++29U?< zyYx=)k42`0zwee>Wk~k&-@m#m+qI!}U*S1;paKe~>YbA;)dcMz;~}jR3j}b2(v6_G z&+fGOfPHAVc1gX15AFA>dE>e-3Qzg|ePMZAO#ax|Vm>?j=A6sfs;+|bQm69!vnCCm z^vlMb!FQD~=D}q`f0>dM2?X(aKZjf>TA>NXP0RZV7-mMrW_mv0T>!5dQY|7^u+A>a z^6YA=k+hI^U86@)%XC2|<(917yjEICwc$#$(aBH2$)2=Vb_4S^?9*XntS)~XZsMDe zi&-P%&Sw!G_7p$Jfc6fR3}s962Dw8*tFG_j(7 z6~}%XLZ>!wN!J1$@;r1K9#o}dB&YaJ3+$9VW=a`EQH%WZRNvTZI8k}ebjWW`fwU=H zZSH0N$DelkD}+GzuXXpW(HzhO6fIf9`L8bE>;E55Xq0`s62CpBuVXK`nu(LKeVx&3kO7zf z#)m9j$87#WXi3L28f*kL`K5d0E~$Bn4z`lKEKE5|HX*%w#tMtwlCJ)yw!rG@l?l9T z!2)<$$sYt-V1CoEuye27fnHTA0b*zf#qxEFLB@5w z(vAKFeciR<$Va2;=VN8!eH()ru~+!>#0y5{jQKJ1I)A|09Ie-(DylWyIVnmHxyWFP zz?yQ~pKK66^W2fL>!1t`SNv492NqIIzmH0u*%EA=j1>?}#;7m4=+jF&{PeXLgJffs z_{gfi1kbEnnbgg_A;_c?9^B7Vywv@NYN>uxq~cG|2j65^q5o^dBHlxjMS)Ii99dh< z|LzG4ZtHBTu{XqK<2?}U7|~zog{ANGkB?>uC2T3iaIx(;_~rXKfWB|^_XyN0t3?LC zZ0{;5owC2DjdmTur#*&nBCe5aqY_ z>2aZxmaUP&%nYH5E&M3xVQg;RF8D&br}1JYY2Py|?;&Nz=bCcB!_B%Pxg__rwHK?R z%kNVFYVMI)DYaI`dFR&!&g$ZdgH5eQ`r5pb(BgtxDCpy)-82F5=Y7PTOzr?dby%ep%13!5b3R1|b ztZT4Kd~y10vcx!t>$ktDZ)H3fCOsPp9clCLufss@t$dwns7X(*6bHDS*R|@V4)^(c z7e)-MKRUghzCZme;PI^S1FVDVXJ75R>Nirbn&MreOz9MXcpI@;lc&;tl+NUTy5idh zOf6Ad&9cHarR&(O!t!6`&tK4MlX%uTQKF>jHpQx_+lWiA)RPBYo85|ekIwga^i7G6 z?%$8w(uXe_<$3YV=~X42WOZ*RMzI*6pV^sv^C?B)Mg+-dr|Gw#eVjZ!5@lq4;XV@w zYQyTRT>D6plljDZ(I;u*LGY8#a&44#ub{UzgQ1KiQ%|OU;Tv<|1%tX!Q-e*}QX?Vh z(hcM3;4vccG1um*(lg zmP>xpn*4p=~FwgO3OW@p483M^p8(1;wm0^`pxE>$dE{w4hmrOA}zNUjP?_ zPK2&&EH-QWenGSDRdd0~k|+i+m&d@4+-uyLpQ}ICMe&|?a({J<^^(VMIdi4dvPhXdp37Eq`b%F$V{gZx!= z`(PT+o2gL}40|o)6YkA7<%cVmM|VGYo*kXrbt&6=kC9>3_^=YY5P61dtO9!zm{3&o ztGn%Z^kjaX*rK(m*@W69(UQH5_!EkS^c?yZ&6GThb)2Sa=&lqDn9n@6I&%CE;JUm!*7~> zyK!!$)v*tP6vzkerV6vS$qu1zCMB^T5{gX^1a7~RRNwVop2Evy{xp$$4j*D`wBO7J zP*3ZRc6y2)()DiKd=_q&A(<(vSYNdxkM|=a_OQ;L+J5>}Q8M~!(7fzTtjBKA`a_*% zZPjH|`4rg~ZxWWEGVg3fQ%?VXHble3NWhI>EyP}nf&v;zD4 zpfXurvs5_$7lwVR%56$$gfdJn)}#UU)R(UBYTd2-8oi6XSQn~`Ds=p^HZQ0;zG!9t zPd+aW*^&@e-LHU)fP8DleS&^1^f8NQ5IetiLTb^ul>jjuFF z1iR8f$D0=9pm2l;-8E3932})j+(1i}xzc}R01O+cpe1JiM#;XA7Q&yo?>V8+v_tFt zIxrMtL%Gsr4{75S^0&TeA~pDE`YR(IOu!da&r9`pxL4;4yp1kQ-Ha#RlVi_YrX~-}z}_!Z zUb`IS{2=6sd^*ur3D}CnqdCfgR?}a);`o)|p1-C_hLxF5OY9oRXwq6QDlp3EoRAWi&gIayb$M$@{h=I~xW zN2}T2GMv`GDxi7 zw09AsC4e(MOfN$HIyO?ic_Uq3`7T1-#$qz9TCck|Em)U~!+q9e=DV-PL^+da>dP;X z@e@yuMB=O*sqm8Fc4%w9^;OQ?+S^0VmH=B5_PWz)s=MHDDPc{ZJS}RZoY3zEHs*II z=4tGt*PT<1|7A?^I&!zTg_;qF(F1_C4TX1LlFHbh(d( zAQ-wB89nXJmF2la3@yC$x5D}U8w(Jt<>v)Ve`~;3KbM#y)Fmt0{5eZ+9l)*Z*02>r z9}h$Znt2XstrPEj@>vK2tj0hHywp@K)e1N67b zFD|)|t}6D)Et0Cye5NE$3`0g$^h#rsEIrKYNo(+GneG=~&^ID0PD4rea5>kq%v)raq6q}c%H9#bz}W2FXvX>Au?RJ%LH;heZLTeZyr^EA8ccAe z6VHy&(#$4rj3BamrGD6O5}}J4XBA!e{WMOcgx3?iMI&-{AYZ?MP0{(5zdudOu<{*` z(e!w!@0|HUxj7OFyo&3PkFy0mkw26swyzUIk|Kv-I-?Ca|20L= zS55@KCgCPbS9&#wZARMg0p>VZxH>d>@FI~qr4Fc9-f7#vTm`D1=YF(7|7x87vnP7; z_R^)W)2#TOZ}Kv`6KM=WhSIUf@C^qEOg?<_k!?qt+Yn_*1 zcH9ytM$gXX!`(PYH_L!4HtRAUduFBOw0jX}5j0SQ%+N&!xqlD7P`R#8Upv}gCRC;u z2dV-b$1U6@VaKF^oNv90e=v$4p@npHBmFpWR*H$TM>o=O>W;K;+tr?oGUa$o6E=OO zM)#pL@IvVe9Y`@as*{3w(Nw51RleJ=BRP~Q!-60>j8WNt8sI7VGrNs=RZgD-;D^!h z{q^%(&XxW|DJHFW-kNVGJ1xttE&t@ppRZjNPoF4StKo6KGj{zLV1Q@RCD};xgJb~d zSk-~cBu0a5A?5$^UCYl6U+`5G!|boG?*pVNvi`ID2s4|5PzPGk5;E31Kx=(S7PvDc zlf2~`98_-3CBM#k5QYM3|LB!XSdpt(rvhQvz&JSVw z8;JhK)D&}$LLNqL$*k>opi@t{KLfNcLf}{X8SB+Js`u<8mGu7OYQ~s6fJRDdR$~0c zlvH9XafZlpsJ%Q}pKR5P80fG^4L8}OM3Z~IC(6u0Z#Z&0Yt3}19~or_1eU-qet?4D z^DFkoYJ6;2!FUCTHC@A{P=f}582Pf=e>JmA0Zlia?n*>|N8BfpBxXShu~Y@=?QulX z|0hf$Ozp-Rptw80ouTXze#SCe9v-y{s9S{0%`c+%BBiqf5mhj&?|Ou#VmUtBtx|7Q zwh}SZgwTY_dApQgfTjVk8+Mu}aX5fl0Z25uyysf(n|^z&Vu+727X+-K5WifYmm;;) z>C;YR<)#D&{|9*j^z&Sm;Pkxh+WbG>K86GOwqd!)I+QfozO6{0}L0Sm4 zpRnyql}=jOlQt2mzl22q57PXh44q@S@|Y@1x(A?9rq~7%KXqOLo*K0re>eTjEjI(KLwhQ(`G4>mw+Nk2;PWqex)+8F56pk|EfwVW0Mv- zVpi~-vaI@Lp<{WbB&>ckCmMlSWz-pTTiAXOd!%U)3af5Y0<>YY5-XrhF5HA78S(h4(=fDpqwY*_HB-{VpbN7~ zz^Ve!PkI%epfirj6x_3F%T4Jv?4#qs_3kWf;9ty5R5#2^l>jCD3&s?+n>|=5ACMR+ zE!sj~0|>&3P89~N2gJb>=s5cR)U-nfPni}^LsgUn4LwWo;+(1ym$_}&)C7LNsr>&upf0&PR^j4ALR89^b* zv$Hgydwi^ZARkS?aI6JrW#4I&$4Hr<@sER#kuPe)TB41_h^-dxtQa#(_BXm16=8VG^qJo_Ob8~Trw)AN?<{pVMY-BOv^U!1C~yU7uHJekSHo11)75;>O2!z$j*tMRtgIk&aGD zY9oOTR{X>Ryiu7VUC=WskCPHBe1IDeo)#Uf68JS_EK>;qQ+ z29O1(Y7)~~0DW3hFEVRTu#T;LJ|W6rszfEosw23XpOp_7Fl?wggiAy`WAjhq;e-U> z-hTn)Yoi#xTNTvUQF6T;!s!^4zBkYoan&dR$xd7rl z+gWw{mVZ>1`OOOeI|CRBt?|1F6D!YVkXE|HBK1i9TVQJbkselepp|_eKyY}oFuVKZ zR7)vYE87$vB3TwaSf!b9xi4{u4AN{sU))JfX`Sjpr|R~WFF3a=!g-$P|3^Ft!HS*nuc7(p5;5jYgJ*c-D4fdf7k}*1J_{- z=oA&AKz}v*D6L$TlNg-pJv@TXxDcGPGFmDq0H+!`zoQvymmH)O;+*2X%Fs=~Js6OU>wgfR&D%Z*5m3H%w^J%1D-@MgF7h^uu90F8a>_=}0zjvGxEVF<^*= zNF?%w)u*J8r*$?T=pQ(k7)EjWajr&Zn9}vq+$zlL7=6s^CbE7in!1#o@XCeUl<+c! z7~&v(KoCzuO6D6-ZE;yJBx>KK0qnX^w7I&Tg%dkK41&fm^`-1)?AYhDKIzFr4nw21 zGvlD5oG9>K{T{gIrlw8P?nicsROPxXj7}|w{hBJ(P)*nYMU<+kLV(^iAO@ODEQS_e zgqKeZd~&rccGy)qS*tw+eA#B3A8 zWvCMeqnumeRj`1^iKd~P=6n#R3#p1IvtAnalH5Rz8a$292y_7HQS&Yxqog4QkSAq0 zH9dG>MQ@bT!CLKfm^1OhU-7(Ov7hL3#zwL9p-3<}rP8Aqyq0=M(n4Qc{v-aSyG1~X zptKZ>3nC!daE=Zg8JiAic^)QYG~@EWpbAY^^mz{M5Cxe-WTa+;`DB!b#p5u9y4{$?Ms0CA!M#tbN?V{;4+MPcZcwiedm)RvH&;ra1}RLRY9K%;}ks<^x^s>D+! z1!R$kdPv+k?f*Mpaq!#BI}7`7OoJ}qPb`6X_Wd`{xoj>&n7ix;<oI*o?TM%(KC&buVT>Kp4(tkik_;Cz!ys z^d|CJVzX$~1z2d^?F$`h7=WunIN>uHuJ%gZRdhG&oV!~KNj$#^3nVD{LxpVu-jhy-_>7ctOhW@^0C@cd<;Lf`F#eW9=_u`(=hNyGWq;m_ zPcrUmVu%jqRw0g!hYu33>yG?5t4u;ii=TBTLUI7v@Q6IOakAZ6lNNxsOBbw1RdCTE zp#rUxszxa($^8#Qy0kA{M;~skWUhTibCHv!p z+PDL{HpIOU`jjVr?Cu|DHs0D?n9)9!l?8C0mE5_I*q8sHC?M(r@b~Cs`GyZaES$jU z=GPh`aV!-!w0#R(p|!mC*wDLhP#R*YQ5whwZwzB|OmK2WWh)if4%H|s0_?dFTy6O$ zjQ@+6{I#Q6vgv&4RU!cgL0d8aK|p0swE7bkLa~jZaC>P^;txpmf3%S~b)4d)FHph- zTzI9r?zoc3Ni%Ej2OegV^y%%2QwqYkcL0>v$wcYr3l)usHN}r3)gt>QsEoqm8MiMk zb@N6dMVwl=SBZX{VT~PMGy@2}lT2LJ)8XC}Wj=3a#!42>2NO((z&cLYH6M1BEX{n# z$(0p(?j?60gk_9FMJmHa1jXXZJ#@%6Ew&mDhH+-TPHY9Aa*s^C8%z|mzJS@+Bh{d; zoO#u5Jxp%)DSxGepu{`&W7<(on0kLW@%ij2TeX^ZHmE&L<{AUcvRJP@bM7|2JIvm4 zNGmHQ=J2`m?|}t%(Xn8U0Ymv~nF%6+nzzLlqx|+8tDf z`b(7t6oyQu|8r)!9m3!@jrgNG08I~R!F0-4o1nYjE@rk^kOx2;JR2`Tb4TX0AE++6 zmYvd{?ai>u$(s|u0MPwyo6lpq$!tIOTJOSB0Mn1!npXglGN3dm{U$-2I+8z z?~S?fKh*Z6TH&_71};h7zJp_`j_p}tK;&GIZp9Nu{(yzU58uE;E8{>)FL%}uK>VH7 z{_M#(OlkV~3!c|)3rC8Sp$|ITG@kE0qA(IDxBx^O+DfgBFU+4C3sDYac|^>B))^g1 zZ9d|Kt$iya-g`HD+*vY4r7bzI zIfFkvU{?Np&Bz_V?uZF{mKIX}S8>OxiNhn_P~oh@CYa0%1z`0deYVIs@!?))!0i11 z+J)xQLC)BUkGSGJf<)fHs%&5}Ot*%DbDVGAx7F@=arfd7Aenz*$~r`808kSc+v+y@ zgBxRZqQ2af=DaMx-~c7XuQ_DgDrAh<8u+vZdsD1UqH4r{Evd|q$!zw&%@XHvCjzWT z`sKbuY&PIe9%o%56y7UcIXqPh7tKD6Qf?4lUbGlN^~|>xKlGSL6|88Y}<{}Jf_{LokusG9|ou1G}cc^ zjFug%>p^o?@TPEvDp`Ph=U+Mqz(q7UR}W5yNe!E3cegN;e|$>A`OskY!`e$ZfS+yN zk=8==HHibn`o9m`08IH_Hc&az$rvME4mIckhbsc43Xi%D;$t_+ivVtiYXD>}0Hu9q z;!jWfli<*kyHSbe@c?vf2CQp+>Y#b%p)6xu63Ab-g57PyAV*D$!fL@39?ZU$^0*10 zCu4=TA5>UoK*Gv{jQ%;kTt2an!I{g=_(zf9mK#I?)fY-FpZVeAv~MMd}@~m`38;nOu{0E zv1_e)Nnk)gIXsx{(ixVs0z-d)g6J7rQSx0U;gUOE0i93Eb@kQnaeNz~?ht@?so^R- z2pgHje|iZ7G%DvCFHmm*$sd;0+reA`MianwlN7iZQUggeC`IH?*;`(YJlv{cQCyB>OOIMzB$5zOmbJG$dAjX|Csb6FHNOq5}<_~Mb3nP*4L?)(P zW2_J%%&J(O-ywgDx1$noldS-P#!0}#G25!6h1+c3X0r*NXq=o>3`dkuD*gd;{04^- zehA;!=atcxRaeh&p)||lAr5ADkoXL+iH%2;o0NGh`KQ=TQ87?Istwh*rz}A<^|wug z5`=QczAK5e19NYRV#G)X&aSBG?aWM3Q^L<+pB|Ucbq20<%Bcq!66m7clVMPkxIyhB z9lAq%XtPCnjivJN64CBPv7=y+K%&S9B; z`65leEC$dVHlSpJq`{Y*B8NGP(LW-#2nRSg92QgPWnq4M%%TkQ5jmdSg8E7S+P%D# zU*x25&FrbpuLEFO0^1G3z>_!)!rCd3v9JCSEw-=!FK~+gPv9b5UK7*syU2qiPaxN6 z_x1B)74;c~aWy$6us~JH%yNlt0kO)}%TQv$)f4nV6-C;3NoS!toAswtHBBaky%4+TVo$XvaTQZs5ZIE~`H3GOqXqFxoXhGA3znSHHEd zsfBQ@!*eqLlF)@c=ip?CKhDKJ&u!_ctMI?1~fB-|<$pv~!>J6pAwJ03_z} zK1XMNNFtg5_&h^)|LSJki4%bK2=Y(MM{TSEYO(f)(_k;zr12X|;lOZS{Y)bYQj2Fx z-(7?2k=S;dC(L1ABK@S61_gw}yH}Uj5Ycna$RL!fE`7t*D4De1MMZEf%PISl4s_wm z$VW*lC1w?o`!_a?$8?VRoAJ$8d@6#+HOX3HVo$0U+yw+!8h(ksYl)<+NqcMP`NY@f zCdVh+{o3C961iyGcPrvsw0pOgBVslL2M_7aya;O-@!W7gxm(#3S-o9+WI?HCK@Pla zzA{~S{DE!%cz;>u-Hs}JzdclPxgI}V;l>#o+sEs6LSuN>=7SR+K~Cy2z%^wzhFnmV zlS9YC3c8-vYK9i=j!)UFM7wx~3VFbd?(47$bn4^c4ZTAs=Y1$MGpWIadwz}^rgIr_ zVrnKtzR7)>)by#p4p|Hp61(|wMo}YV0^JPTR|C2WUA*$|-(g$TM*)Uo@cQh;e~{X< zx!j29)EnSFqIY-hlosVEC?Tt8q{ZpXgo-N3Yc5_nTXK?fK0g0@j6@(_yW0C1%8=k` zj0ZDe*a#9SVY<%Yyx#zgrdPI|F^yN#*6!J029;Wo%E7J-A8*{GWn`x7FHJp#DMcO- zLx>?(Tc5|#_~x$WhvLWhoHXyyK4(K^0|Sa=9veHK$01hF5bTD>#)|6d)J_r{rkJ)i zag>Lv?#22kxQ{hG_+-)VCU5-n^W?cpLk*3AJdOOTmfJ;)mimQrii4Wd zv0skitcC{d7yECI;4aP48u?oFmpOln43zctg(z?TCXmA!`}>$@{tG49vj4wANyyhH z0cH8H%kp|5F#kPYdzh|q&|fn~5<|oKvgI{>Q6)_+{^@e1P6b0H{h zIN?CySvYe>IjC73TibkVd$K0CU^}mI@bckM*bO{;e>s0CQB-%r@p{i?c6ETK`4$?h zWV?}@n=2J(`mK~`eqP_a`TX+yiS03MeBG3IIu1|6v99oauF5we)QO3{d8aycQ0-}i z%26DTp$Z#ORz?OajJ2twQH1RE=?0JfkjKKkvMg%JOMogRV>VZD5HGR@?O|Lzvjv%w zgX}|Vy>x_7RIpsWh1VY@WmvHEGOpHTtfLeht(VsShUH`A&wV0a_0!b6K6O<y-g>`wKlG@LjL321!k}8JD8nYi%}i9sfFOl5$Jf!>PBRScPL13mad+?7 zg$oU3?D8+(b?Ku(kX2Z|%ph{=gyiUU{l%K(K9+hi-<1_u#Vw`xmKchG`?VB`g@q+< zN^?RseqrH<-mFEux>G3(A@<3@y88Oy&TnMAC21QQW3sRgrz!oRu5hQGGN`GJ=b3TZ zl7is_32e#78+>1Bd?CT4kx>Mi43%_%|6yc*&i%OwV0z1CgWNB;^ zLTNY-zGgVYdQN7euB61wsL4yI5^I`xzROeM{kv1u1-NIx z%)k2r+@lIoV24OPwfxvg%S#VHDt&4~1;?AujsWI@y{`k+d)x`iTGh~NbF2KN;99T! z%wpIMy@ABm%pPDkK8()HGyM)LIdO=fmPt7uXDq52ZPfhyxn;6&5XwY=18ZBgLsf`3 zJjk-K7RD0wNgEq{xZCV@ZFkZ-4%{tT!B4w-d3cqaY>)|iIY{{Iez3mzTHlyuLqC3> z4v32SQ^dr?7*|9h?Vw5vE|f^Y6P)e{@)s>-ObaI)yKeu^(zRQ>93db)=ymz9#Kjc; z^?g5*^|E#r<4Swg@cPRo`@^SUC>_H3b2mV0pmt0X66C& z?dI#kL))$4CoGMQZ+%HG?pG-nhngR|-_|S#@~DBz{YF|o#2eH_cXb(i-WgY?#g?HP zkUs1$KE&GiYB>+E;jr80=#`xA*fnSU7hBs^dR*{;Y9Jrf)x+aBC}f7ATc3n6)N$c^kJ;U zGo4C*1}h#i`*!A?s$z_8ZT?}2Ja(TMPW2>p9j8&^|VKG)tlvIBj^2Dz}73Os*aG04U{Eu5c!#Jj<8*mefD^Kml(p}`erdK_NgbnxKZO)sJh2BQJ_iJrrN(;GsGG>Q}-YM;qyigPmI=K>3^X} zh$CL(zYa1}6u>WIQq$B_R#CakTJ(Ml3i`mrwD#=KhgCwt|8E}m{zFD+sJve*%CfEN zLfOkfvRb$61jbRY#KY2;hI9$Xa~CBKLpl>MIMZA=i4kwhWh?U1>15+s_0yh416Xk2#|XLSY#*;8!;&pJ@Gfv&awfEXdCj7q>2TPvN;Gr8$52|&1i(YClc6EKJKP5-A^6$~@15FCby>cq3=ZH+72YFM2UmUk!pg`v3F38vh4Gy-uu9#nw0f z{I6!ZXKZ z&9nj>J=xZ5kWq+IDNsg~#s38pmp=LkqDt<3HMwd9_qOzT#)>T+f=TMF-(YGS#V5uu z3sk?>tQCRaVJhzVVTwZ_n2L`+#u}Wjq+3PIgad5^1GU#tR!vlq{N*Xvz@1P~=u)^YZCFMSQjU8i}|~D5SkXOOTOtivok!>ZgdE9JQ9&C)b!1H8v-j~Wd@(BDGUY#({;LiuH_Wo& zh#_i!1a-lv*-5ii*ft7lRg>p&3Z;L@l;l3O>~t^3YKFKi^gRY3=M!9^C8J! zOIN63R3~#maM>gVHr~66P8z2u&2#&;8^t`3EQq5`@V` zrU_uM`uhX>T#h^0$bSYdQLL;PtS;@MRGAXk$mGVRC`_h4>E7fwko*Qg$nP&4mGvXH zhGXdv$$ZeJRUJ%N#mjt=r@7BqIxH(79QW*~l>H4lQUW3YNjgzf)*SHjHa@|9C& z_`~g5KdYvfeh=D5hGM5YE@gR~5M-1kNHdN-VpwVfy>NJy9G(|p1&uxA!*D} z5#lXI>aX^9Mdu_@s$2n7G>n_gpadZf{eFu22DNe#aINDOb8kGvqsQuRtM#X)p$Q>I z2`7q@(H;R;+cva^*-orVUnx}bdt^tvc7(3*tlyR7DNV1&KZ9FiAI82e%>JmbjO1GOxraI;KFC@Bu{@f9IzXst2_6Qel8;L2BRk&T;_M(akx269IJHQQcc?)^X!#Avo4Ov}dgrLF z-I~GvNoql}8o`S#zHv#kdEMiAyvLGpdZ4k-AG;t&y^VYDg){rJFb5SqpvD5;Q)qD) zb5z|8bH=WSPGC(aKCns~k}7mB(WBbKkeA^0Pp5kZ8SS4=21bXCeO6SLJ1vv4U5S0I z7^fJ}w9zcQ;h~h{)3zlq0s+1ee?qmV|JMW0z#f7pC1cGbXU%kqL+u@*5i_M^v}Vuo z7fQ{Fqc|^*`$XDuIo$XDI2=^~I&mV;Ts8BSTu>d;`#Z3lrIj>INVA)aE%Z5@v&zt0 z^#pq&{b4R-64uqf8~0=}DkZ`f+d@^hU*7>XIk|}b148zj-l|`t#%DnAuV6Xg)n?nd zqD1YjvqPPgC~FDhJ(mdo5=j7)rkb&NM&@1D^>l8tkERaY8w;B)iu*A$GmBNHLN6N34g^vS85B zJx<6}@SdVf9IKP5t zXRrmYnDd2@*ZvV<68o^bN+P_5Eb0VIHNlv#9!S{F&is+KGJyZxc%=#0wGmd8Cv?$Q zu%r&e63=Ryl~_B{ar^-)U`z6l|3l4DY>>4?F>yJD&Vvt^I_nJ$BITi41t#Pfk`F${)P?4mI2S0Pr*Be=QP6y9Il?L z&onybII=YalR`8UhT~b~4kj$p2aA!=L5!~!alo0?PDsvfmHtBx)~F==?C zE5wux%E>H0KVB{3Y^C@a&F+>nB-|$_sk!} z{gqz4B_vx=Jz6=d{xokN3yXx)Mpg?eN1o-6h9gMFu)Zm(94w~!iNK^6W5Jx@dG#IV zOWo-nNgpCsjLmSgk+dLIZ8F!+zX5|O>#ocYo&wfpATCE0V>#?p)d&u=vqsG;mbTvg z^Y8)Uj&Veh__st|D9IVMTCZ+e5LQmJ)~5a)1GRljvY{3Z)YuaQSOO?%Ey9?rIw*Mh zXMEXG0`*vOAc8}Lr3th)?guREeyd_nwHDh(>E;IPiN`3n5h%=+ZaFAw=bSM|sUia> z$c;I{a&QQCi99F320ICR&*{5-UH;){(zgt6>zH02BX#b};r>^38$D!J6E~INW?4o> z`KjZPOHBkVSa>=0R}J!@7|(|DpkWw!^F(0uCxn&X3(;SUPkoPnY`f^73lQ`48>d;t zUaRj*$35Q3B%Ez$m*Ic4Ce*vwxqg)0ORd7mmzwz9v4)bMqI<=ODdCjQeI#zp)C+>3 zJZK)X&`y$kmCnt}5@kAXM#NgZpF?q`qxHPSv`(8E$D=73r*+!GiBkRe#5Grs3?lW2 zFuKPMJBcb?vz73YMG5OyWpve5C2~iJn^YA&j@K)q->-&jP-(h(UV#JZpTLF=ti7@d)vG>q~IModAMi2bsLx}y~tW3N=u3HAK`M_Ip6 z_{4Z4W$z1aD9&^-y4jI-^x3)+59kw9m2dm-iDJ+fDjwGWQtctOKnMo%ZGQLOD3H1~(_Lj; z_N;nUkxrtTa6aa`(F(#ha({y^)b#i>FRzK#l^&2KN(6zxHj+x%vizd8e4sy|7{>p& zMgluNf^Kxs?q826E1PhIR)D}4)ED^`61+F)KgL);#+cfQpvR)!dIiGY-Yiv&y{st+xJmZyBn46Ws`H4N z1%u2T?h1yAIsHmpkXj^uROza#&gL*nv|U?KPz<_nx1_=J-R|ERlpYi@d6= z)eK6CT0}?(uhor}?KUEzL1fG|0uvo?`9fv`qa*LAnRH>2B}V0K$GGAle_EWfOEIOO zdgb2n-kDg)R1=lxRs?Ez1w~-dgrW%6Z7fO+>;uIIyYegt@E?ay=-d;RCxP9pkqOh4 zY4SXpDH=v?M`>zYNu-``&r!cSUSo!-?k_gKlATk0ueVDoUO{vsibqR&;0tZT<2b<9 zTvEVhHY##_i?{_c*|&q7gaY4`9|kb3r)($kc8yG<@=kN;^JAXw*fYN95=zriD=Bu} zLKZ_mo(|~vCFn%Weh)KRz#d4yWDgjnM_s~P(QZN-C%e#@Opf7lr%~7xg1vc$mmA}Z zav-vru7b8)nO+T?)sY0sR+J*-K|?=G3vo*?gu%?nEYT&>0NQXWXJ}0K6dthN3$>a8 zho{yxR1y5UlXNW%X5Rp#{msdFZZRD>8lX_m`d;oZ-n8slAeYMi=_@6_`sCv9X2iFx zBIb8h`>9wz@3{OEgoKg0u+kR>90w2cuZiw0QdfB-V&USHZBH^JV7jK}z>l@95x zW(+w7(sdumot$5NLg(W@+{beau!MAsT^|UtJ|JXHtwgb|@4ym&keV;yhES<6rV~_t z*N*t&H?OO=#}(x`-t<2I+zKcPq1G8a?Vl>KJEzhE?DtuM(8Y+*Piuy>qrf{j4U$Bt z0b%x&sIcfSf7PIlaX@HfF-J6$4t}eP7DPsOu2()Jq4r>so@0=9?h6U8yR!QJIdj&7~));$@bycAFju4M2;^U_nV!9 zUUw5tgwaMOkHe9NZt*3%ap7e1|eYxZcSFv4YTRdA3CZo( zpU`TW%L3oN48$$`gvNk{T@Z>;qJ*1tk|a|p3UiwA&+dr|rPoDu&u4n&?wygmYJ0>` zc;%-E#BJ5j@JCz9}!3l88H!DnOd$chIraqIR>wZnEM69le;#gMK zU%v!2FsSij7|b`*oNf@Ay501EEvzD=O_7(#fUEo^JLLD){UX4#DRK*8_qJj*Y;mIzQXsl}cBXm1 zU33v2={fFfbBd<^X?o0f{G{Mv{!A`zVLoeUUcokDGft+e*#(-c-9t2hoSkNRIFaq@ z0%RpX{x?~OCodPAIxdtX;;59#65pR(%MW6k-ru#l|v zI(%@o?6OA9PrV70$kuklN9vX*H2vEm z|47;SvI8C*zqu^I8@}5VOXTOA*a=EZQp*(Oi6-;e@KS3VqgK1+ocForrHd_7e|o=l zhZv$k>fuD!RnzA#1|}tIF+yt{B7}C}r)D*@&&EDGDpnBn+RzhyrZ^;zKdk=JL*+}h zA}`-f0dm;xTPS?UT6XV;Kk+o;;<@f-YUr)2y+5(t-EbSyYp~m4D4O9^Ur`)Arvkmp zZL*tpv^6JCs082`_;)>egN+=kG@$Jn>SZPSWVP1bk5 zZ1nD8D)TQt309+idsg+D2dmh9x6llaE0?zwp-QKdUzMLEeZ{{^eh;v(2$2@9IP5RH4E)`irPP)+3RZmjZ zk|4V@;fIkshWYa!Wg-LiFAb@g)jIIM5qL-XHWTjev4uEnM|r;vqQKZS~&i)izn)(^EEs+Y^33*Mg}6E$7# zX2O55`MA4v*1j|gnB(Wsb)nAHp;m;9KADj7t$ySCfq{kwMM6Ys4` zN3xbBYB{gFg;kQB&{#V%&ae9$vxV<7Mtiu-b>N40c4}yM_AC$pSD~-AGw!-lq(1OhV`I`DOYgLN{ z=_5MiEIHTyUrPDdzoF?uRHRYlULOS==FJJg(q4V=PzeT$Z^a{IZne+`T`S`8>_ zL-X@(b+{T_cRNbE|88|e8>*K((_K0v!FKwg%;EGu$5zlg9Tk}B@pawEXx>lW-I&k* zi<244;0q@6{tC0^Wr5sPlOc`9yBSO}#etYE!UE%G-FYuu%j`6`xKCVmO3OCf56lF1 zgvHYpkdUiugD=Mz*DuM0GBd06{(Q98Ja5!uQq>4dZPdaBWzUZ_0S3P2yT#JsINj{A zg64Bm6g^NIM2!TDWX|{gq3Qlo8U8IU^|2Yos@RO=#t*7=G{sjxf8)}JHqGx9GOkIL zSK<_AP5;9%J1Fe?^s+7NHeRIH?1;vLsz1WgkhBsr1>ex2E2pIr z*g93o)Xd-Yx1+u@`ta`VMXUME<-roq``wa@f+arHPt!hsw)9&o{OngTI^`caknWz z?;e?S?}nZ;=iO`4X`G#!9$zcvn|AJ5LP5biZ{cm~iPi359ZPz$_K4A1B}#3n<{P15 zyAMu2JqAiSA~u|IXwkfKYWO?9I=rom?991dmO2<7Td(KT>ZRdPWzM%dF29`P=(U*% zLRDr{ygc7vqD*hySZg}mR%E`hNI!nu)}cwCMAiEq>M6i%}I zd`2XEH^VCVHCm>wR=cw;j!3j7jl>ky>&W_b-CyjuFuyo^Xfehy`Sj16d?PxEi7Q1;Mu$?wsXNicp>zU%Kujv-Z(eYja z4&T^g9EdPigJ*VtLpi{<9L7l+qqW3(FP z99AzRHQC1c*%eQ`uUIG}lqF(vrR0RtIR5I4A36@YTuXm}@aGzewTCVQohEaW z4p%uYdFSh`KkW{#Y0Wo@YI}vSvAB+3kMyFkfe1Ov^27WO6SP8``}Xw&WJhr^TtOl@ zN=IciPsTFe%SJL%#@hZV>IhaU-=7(}EgfRp(!$l2w3_6b))1g8k|<`I)Ix(o&()Qlvr-;h5 z@U;mcs@YZN^gZbqHEf(zStOkfN~WVY`+L2v?kt6I^}Ww3kKymMY9eZUSL3kZcuR(vPrEV^*;tO3VlKFW|Zgbmn(161j2 zP`|mib>WFCaLS(ZOL#?B_q^COzpnT%rj1gl8i#*lKM~Dxe?s*=Pw$?3XUCb+tnkCKG z`SF(Up0Dew#{w#&%prderj6v4a#wOEJOjkzw+wK)!euLdmzlH*ZdCy42ZmroOGO9* z6&QLnYDgtcvdh#6*MPTr^j$C&HkGfctPcCQb~YIK z(G$YN)O3MOpSy98PQ2GWAiSVuu)2){+yCOlz&*C#>AtK9a}n!&$##+&;}j)|-2&Hb z1_VT_vWLP!fNYCt!lW|-YQ(I%$8%a_2IK0A4CZ&-an!%ZM^I!@thpfAx)SgUpVbM$+!t3!4=F$Ml6Ies z`<`xvp4a^^gPr zO>4}+qfYnvT&q4NFf4}{Hgx>LzCIZ^$P-f`bB_EiH}Zs8Q6y^Cz<<_2VkklgGmByJ zh!XarZ!9qDF2&z61U*$qtPXtu4W6|Mpl0rlJJk{w+uCykNb!qv7%K!IWieTI)VDpH zu7KZP3{<8#b%0U;%;J!C%IvF^M3V6mfN0irpDd-_X`y6)U;?x=$AUDW6RN!gO++=; zlD;W&jn4*-A^H>$)3aP{6)<(QZ~)c`y|2h3{bP#*0CK>GyEbHO7lRk;kx!qjWU!`m^s1niNzsDlM$5zQQ>LUuwg90J8mm$M1Xo+ zZRc^fiR`u0P;nJQhlG^LRYamN4Y+fsDe(DwclLgU`B2uI76OQ8*nnHTY&Z7j;%qK= z0BU1EQ7klh$++xk70r}4EI>#AG~~zcAQ27BS?RxVPy+JpRA!zrQJRjVP|_2epa0@h zRr1_aX6R>|;wpfQQH729xROZ&RO@29Uu+s2gEfDvTaIGSDqH&3^IneNbbW6y0tG{$ z0jF}4e5~znler!e$gv1h^h$A=GXl^n9k)jAjR=F#o&jkAT}KWshJgtR^gmd}0bsD= zTYF}2oO10TTP)20bJ6<^{X2QsNqkbJyljpP2Eh` zdgI%Ze!s{~w8pMyzn&LXEOq=3PXfhM^rleSdxSdty_B{x?B?3-M%?c&@CLZ!gufW{ zy5S4l$9;UlJgM=wXl1z?$mK30*$hCct_!;SCauC{tg=TEM@?T7{Bv4w7Bhp+}`>$m7e7^9;Wzd6hq>+qbVf-wQ(xsF7UotwMV*b zM>T5(!eB@fr@s;B>{;_-|HWE7K+GG_hYJ7&;QM~i359q^W$_|QIOR1Qh3{52B6tL>K4XvRK7bBFpKK3Mud#TxB$6J zIvby%+FOxhzYfz-{(K(5zUQD~%Oc|faBy9m*Dn`%I+}%qdm3s4n&dxmNksc3nE?*^ zrpd>6U{|)k14PqxMWqK(s2R6IYFG1(zNTKv53E`0bF^Drgz5c3mOorI0g_9c{wuY> zkIOyS6cH)hZspv{Q1%D(e-j-I<@_ihDgn}`W29Kfsx!CU-|u*ObGzSf<5`>~KIh4t zQs~(0)UU*pWz@6lXhE)pALw=EZW6wRHuA#6Kk~MukJ=8Glv@C-Nx8s)5x zTriGnw*ZoC9JGY>-r#kNO3({G&x{@Q%806~c8$*KRM!((wrBBMOCtq(3~8;KK>Tzu z-Ip{dryb$?F{yLUc=gIU7=SAQP%s~oLSBTs30N|!K&usoe%T~ivysu)?qvk;1NR!` z?`@p1a8k#=E);W<>08M+vVBK?Blneg@B4d}FV{r+#S8!f8Jt)k);d7!M3|3)$fl zB`9(?YJl9EVv3_74&XFdRw`U|?l6106DU%dJ^!uD2*73$bHj6Nu9Sz>IHF&+udH1@ z`A+|K>8WJoVvHpR$ElYTxRdsQ8g;r$2~sI!gmPyMV(`722lnd{J3{$?2@+tayFGEn zhL;Kl91(F?sx8ngbSQQ&I!Zf4*?mt0;-B12tpwcwyp%&x;1pajJaMO5WHk#!%B(Lc zikz~Ai~Y|rzCsDlCidIIK`^E;xXc47nx-pr6Oe%@g7!EYuZ9GqhSE{+W{S}%hN;E3 zou*kwe7Ug;(X}*3^Qyg%K4M@ACp4>QMAk!ts}sTxXPwj`JUwP-K$e<3^*q%+jtoS$xW9g;0=E$EL~7c|Rqw*JsNH!JQ3Ke8#YO!? z;wI}#ObD)w%7qXs-BQQD>j~}by)kyzD^=uxBV~)j9Bndj2U84Y?<}L<+;g^8+uw=@ z;v-Gl_?Zw=&LWPd2IX%qSUrTInGFzLcxfmGS9m|3W0GF(&+ov#N=D<|X$4`_c-du+ ziF${MSA+D?LhJ$Fw{3V1Tg?4H*ADkdy%|W(=0kh8a^qn$g zur*wOELps|L=P!N=k&o-?%ZBdgW1!U`4mQ3il>N2OLMbySR>Po2ShV~ z2cu_e@qoC+$WJEWVw7-%mixb`3&sDUF2XsB@$G3JDouX@MH!7oz@B0^&+$7-NHx&m zKm=4;?AM^7UzJcW-_~OA{%;V}7eRp32y~(ldW>_i)~#M_tE^|W0Hg%y1^GQg)nwiw zCZQc^z+}_+8HT?WTnS=Avsynb=!%Dg0Zj%#+5x4Ag(s=m^xj<`N*5$+D`dyaYA7`w zeBZj#erDdq&1UEcz<+#)VAS zWYD>P{HG^oU?H%dumx!>b}rS)##+*&1=Eh^%Y_iJz;H$Sc|m3cnEs1ceSJO*0|dXl zJVbnuYJlK!0(|g~Dk8`;!wotwPz9U$QDp3i2Pile+rUZJ##C6xZ7-&c6!*wsGqw~? zTc#)w1o!jarifK+C9C#u*t|8Ove&gq0%m}O0Kg1DKeT$+!k*)w^n!^1rp5dT%3X8$ zB4QNfX0UB-+@PY3wIArE(CQQ~yjcTM9MD>`W*#obzW3#i?kDWxC@_o$#l3z1_gwH`?i7bh}Kj+ANOG_ZSJw`DEx8qA=HW5 zo9sWRXrOEw=m)DXC$QMFZK(s9(QZcT=8DA8fI&xqc1^?4#!rBj0`W5o#KJG_u z%KyW7uH4gfK#Ejlc~>4B^Ic5ExbLJQ(fvcHw9`?GuqAF#K(r)LU1$azkkSG}(%k34 z1ZzWDpreNzyqQQon6P?=E;y|<-TX+gH3H|c>pgNnWd)i{7GzvYErYKRBt7|_CJ3UG zEySMO=vQsoTX+UQ3i9g(Ta>~LSCTD^y2fTFbl8|s5la) z?@Txd1rn5mW>Z%@80uD^5-bh>G_@CoUt`YgJq~mYuDMt2dqXNz|9wMSl`QW!p+R)-OsMY=YmHrvnLH>UnG(!P2udQ*hwt%H-ZGZCL;~iemvA(yQ zPpjBXU&}^o-mY$jQXy3C_qj9jdKfvKPb3kJt2QlR!?ZVGONFbBz!2a<&N^`i?vD2l z+KPktYuV2I3MvDF`z!29I*VX*IwYB+OCdTWuozFKdd`!L*sbrj2tXu)M4N&RJFbQO zOF7f&R~yaX-M#W}ndjeECyIGh-zL?A?X(eoliWUM0umlJdd*R~+b z=E2iP!>rt)(++0_K7(eHw(U9rXwbVY6OXq6MkQhUhV*>-a4pb$>wGAGtu4wJ`_W8i zRh~cb8?55AA|*~5b?TVe(}$XwViV8i1IojJhFmX4D#M3;>lvO~luQXgW+Geb@CTkb zw#}XN^>$HWvGq^4fiaikmg(c&Y{?vZdCe_GI^QknW~%R^4EVO}dU;tn9W3T6`hB4t zA*HRhJ>=r2`tXH-OMT!7H=j>zB0xdRRf5vtHr*#8NB4kRXDkLBFDcI9(pL)d^_2w95TY&Klm4KoQlc&tl(_ty{W@_^R$Y^eJyoDX7aLj zsbSjo{Ykg)vAYeQgjRz~F4#!M#G zVN7NDvZ|gx{lx0y)h~OIk6K?%h758VS+acj7x?w3Ak27&w91@cGeWi4)It z*OqrfgUQP~_1~`ll{xbD_UWZu+_-;B;KC;|jJ=(P^y4 z?+K~;o{hKeq3IlpVW=(CK3dqfsGCds_K_=T-{qa6+{n=9pWnQA^5$@%rmEA9o_@Q3 z(tA^2quvdVPcC3@l;7GH|6=i|-EPe=MF zY~mzM$Sy;d!4>(CLdvSmhn=b(V3F2$aj!oAU`G;RrG2-{!nXBst}2Qt0GM@%VVkn6 z_gvE;x6ZN%jbYEeFRtbsDy zy?%-ge3tNmu4mJ8ht{CN zWnA(}UKxvrTDOCapycv>-P~REcus%Yo+>{Ri=*rf`grQuh3;QECECnEnW^EzhSTcZ zIx!D|Pw#DzkGcD~S+=L9Fcm1T@1_CWc@d`C`Sw+}d*K<^r?cjoU8O^nm%?d55lt5I zqrJFeSkr=cQ}PQywW?Q*F2@${%-_(`wt6ewXV%PVGO$GJXiy#zt=Q9w+l@xUx|@P< zT^Z*-PckpB6p#xt5qAMJLaPItDIe6Hn-M+AKQT`Twf@D~C|^ck^A`&bQKsX1iSK-u xp`j>-A)hUm<9~uVvFhhHco2@;4S)AKi_+Kq{2Kw3yo|DRm84O?e*uf8U1I0%+ z-tGCH59h;s&U)86^I>N1S$jWw@B6-=>wd1^+HqPMio_3|JOF_}#L7x?Iv@~+G_dW9 zj|+Ua^s7_@KiJ-{mEYh4pJ05OSYRK+TSxIVsCt5K4+LTYDa*;c@y|K<9pF!ApZf>p zuIHoI)M^4VzBn-H+!XVrWAtPq8v=-uIk>L~czhwVVbD6@H-|C8R$_@F|5ccLn0Qn=K^LWZ0I~vDlpWuYVjT1{e9!%ODq$(|6^$R}%FbDaAy?i*cn} zkG*W$*P0d-5-Usm#(X$8>eO9W59zFB$n4Uy=Mp_6YSW|!cAY&!A5#*07o=sjTMhJiQ+;N#dD?{Y@)IYl0{ME} z94DNTv8JJNQhQMRZiVz(B#UKNbB5>H!NwZG-o-paX1xnM^$ypcU~iOo#WLg4Wg zXPrQ=jd7$?9Mf6yrC?Aa{}(+!E?a&->e&Gk&pyihWt=sYt!H?X5C?e7V^KWm{Y_1b zBZJkL^j+4K)1X-`A*X2t5ab}}S)k?@N>}irCx|BM?>Q*JAq8;q%YsQz9nN4Ke z)2gr#slevEKTFApPqboZLkX3Mfv0raH&R8)@fu1TM%0pb$bA%#t5Vdu8Bk(a^$5|I z2|q{Pxv)J=Q6M>y2hiMGh@+lB2@nqp@qpd z!B+{6gGz}G>JgTX(6RXb-2rh%=p^QC&`1o{Rs^8i?F$mYW(?A`^*Ou zbNag0CEPIEY1rBxgq@kXIm?mGTawflQdP4#4~sDieJ7W=VD}6;Cg~g z;@ot$kl3GlRp9&w+f|`0g=-rKf3}^Gtqt>gT<#rZ;2_4S2fZ;$$T6FC5ovcSLSA(% z99L?uePR$Ejh4G@Wz0uRX-JOe80s&87QU#=PJ;WKs+x|F3fORL9 z?(P+8rb}+zT=Xgs%W2K{^w2obBR3l~z9KkbFHZ|1;9ip1pPft%VZQmsApL9GlI=Q{ zE-+H=#M?ohw*M=PNiEWz-1f(&$&tRu6^2r|oD~ONt|L7IV_v)MrGZ2^SUo_*`q*Ig zV1MR!S9F$n)QTraY+A6Qy~(N!y_8l5TFgkMME*$h`Er>sxcouC6{o?U750SAOe72^ z2a|i{fKL$p9x@YnLHdE5bHJn%%ln9@@uBSTC5NfVWcz?pJaq>mC7bi?=$SoZ`HV?X z7c=FCzKtsb2uH=5aljnJ;`tdnPRLYzabWzUAnyXoa2vSW z>3PUu1ypkPM(tePbu}`@vb4&Pefa5J`bD81qbySL?`gvs9Wbjd4ce*M+PqqQLI*Jc|1+4yskkN zVa6Qs-*B9j^1^Uf-+rLy@q*h|Z+oHdYW>k#PZ*D1Q{l0{FK@?tCnV#A+n zofv$fs`OP*5Id)EzmY)wu*a_qb;$Cyfj9&`43{2{ax>2mof6-}emR?!)*-q9jT4c? zlKus2Lyeids6RQy<{TdCB-3xl<6hj!`-o{c9O}aQ9-=4UCbi-JY2VAn9KYj4z!$~{ z5^^s=x9oF=cjDF;o_&o$@H@gInTWC*?Nkf5G>I7ei8|V-bc*_9b?wJ`gqw0cIdLuO zd-b}0)cYlDHDT{$(Z6X)+ksH}TO#Ttrhpt?J3Es({fGq`GQ!z7gloJiOFNDY)#yMt zJk48!7D4K!%B_**Yd*ZWLu)k@qNhUpvku6TSz7RH@0lUW4RU}+MMF!7oroGr)9CSL z6sD`>_=mmulcZpL_^-1ZoiQqKz9}~%*+RFap5YlUb8&==#Xn#w+eYo>en=4yoo%Pw zU@t_Cj2Oij)@ractoEeo#!Rm!icS%+vcF4yRE3r_MzMq4KW5*wQ;Ma2Le+F0MOP^2 z1;shsk{)eV^~ZmB*6TANX_Z^9x&#IenHD1<6T9Uuvn{_AXeF^mJZ#^ZXC3~_HV+rg z69OCG?Y8s16}vXf55uYJiu>1vxW4vioTl!-1VJM>{PyIVc=02xBdH4B^=L|~baZ-< zf$b%M0%LU)1y6RpZb$hoD2%oIwCt}ZRYn0FEK1O^^-6TiVem4Dv8*!es%<)6#7Z8n zT;CB3Ov|PtSCC=t>A`R(8PMsJfG|wohyy9EdWVQMF!%qqvYW_Y!aiIV1SqusH#fOo zIWyZ|;>KYj8GsHKV2>)-AFxl~;PHfzt=?oG>34XNpD}FEV8QsMga$rLv$jGK!?r>k z2C@aNY2)&*gyI^14|N7(3Wc#k@|n~iFDOLh8pgqyarKVRH!=t`ULrk~VbBOyxzu_T z`uM$wl_eWGS9eMEZ(3a~xh%r;1{veZR-0sKZ2qSh@m#Mlri7F!2$M+@`Hoeld1-ve zJ+auKz@esXEZWv~audQ>jzQ>txH(c<6T43@^`!*)TJ~pY%}Uzxo;*c06aGfqvD+~R zex!i99OffzX6v~BwGtsG&?1XYWFs$N!hfs^mX;xV4pj<|WU}E&1>wjH8M0tJh)k*d zZ%*K?Gz+4X@CnYGNBFqEehLgrmCB6$|2pIUxWund4DoMtbW{YyUOHV#he;hA9JsXG zQJnes$gxDZtOWChp@&vaNb_%kp3u^kvThr#om^=N@nhYF6iJ?32QNU?pMTCwTWFVD zZJtc4bIG=`a$6^l<{tk<{%0duO2$)p`0JLdYgO>2EGcTi+T8ZyQ0k=o{!UqCbcBnW zo0Xp*8Lt$+xF}j_6h>_Pch3!KJ3Srxal@y<5Q@`%O`JVVoO@f-i7oBMNooWo@bep2 zNi=CnzE`t0oe%JwKdB@C!1Q*n^u}<^UwopEH0-6TQJA?@M@NVF-KD|hXr9E(!_`Wi zN2IjEuFRj+V92=)rT&dalo{XOEk3}-!*y_U!!9`AW#*ZAH*fOY<5^ZI>MsvlcgFd? zm$`ep9bv71qO~F^hJ}g2R*XcX0BdOu$Q8crA9DM33&wNuL!>h<;x2R|S4?cXHG!Ic zZ6ye4@8~0xltdhS=bc|74Z+6NqR_E& zxEKzujw~XNtnPXT>Kk8J!i8JDkiEAgOH%l1)(NrjC4uqOxePqj$bI?I@rmFQdvPjn za}#KcBaP$e8$H`;h*R(H-;by$XI58LoxLgI3$=8N6+&emqWp89rMOnLU)%ELcZ%xk zb8?p7;-b-xPU2CbEYpO&b9PI3GB!q~rFKGyU+y#~CnFL3B4C`8Tbut+EBQA~{d4E=T{%-d_q=eK&O>_>*sBx*m2WLL4O zZ$DuV;^TAp!oM`LDSMeu3Jq+0sceU22_Z5wXkVvNf|aetubqKMd!P*ANZ$BBqu+i=Chk=fN!@xjT9&(qNqszkX zh~f)95WMb1H8mM+$WRf+>m$XMgGN8Eqdyq&@$muMOqqX(Y55(Myz3wnIa=+RupFMvhZ+SsDX_>{26C+<~))vuz6KRBi zKZ;%y-onN&K=hRbLWNp%OGZXii2D8r=3g0bfb?)I2IA9i)~Tg9KC2!7%D#`sA*4PY z6YSc8`YrF1NRGXLH)@GXUffp9A@F04moI|uZWJNIo0a{~=>vm$4-v~+_~E^UZz~LHq#{HS-@FvmA?tb|!L5 z4qRxVww{v`VdETDoZvnX{FM=w@AxPdy_7U_zq3EuJ)Hmi%v znsa8Fd!9|4OMh^Beh#RC%E$;e3NLbVi&{&&1*DUfI7ps6cw~D;V7J4hyrHh0XJQXG z1{rqvVtr{3_L2>7S!_%jd0OUH#jEa+^&?*I+wjC^MNefeF*kn-K+e4BTA0RixEesN z`vfMq)S;56cihP#alO4P)opIv$)V?j3ytFux9Eiq^d#u!m|ALwzG?Y~b#e&hDD0*8 z_KW^{7U+F)B^)ryQF&Iypxizk-%-}o(euRV<3Bm53sXb0GAU0Qv) z`8zIqZM~OX9&^VMk!S^N3PEe$jspb^+9mL7i>qxYApo*D7b*Yg6Vw_m4BYl17mVp` z()K>|62_yet*z|rJenCrx3xu?Bj)Ejg2goBsT!<)QivE<&c#)ML|0T8^fSekI?}v< zwq2VJCeWMd-+K32fou}{;lB<>#0+@p|7!=EL?KoL=bw$miv7LoE^p54W5@5k6kkQS zRzqB@AgRGW5nK*{zZ>aNT zZ(+GoslHwMel-8bZrib94{O6}>HU^wl+e24J4hmjA6MQo8`JwJY)iEnFGUAUhsSwS zu)T#@y5=lnMb^J8!j-8qK#Q%P1RIL-*1vPsFJI~k(JL{ zZkyI{ssT3gK>!De4$2V!88%JS52S~z(>F)}UMA3w7g)3b*3tdy3|J;pymwWYC~87_ zw_>>v0{>RI;KBGmF}N4vbs+E+kX!=m8ZF24Ln~x~7uu)QtZt@-9HEgpda>?z>L9@I zV=EJ{n_LlL^3Ly@mZjd#2|HRFcj*W1NeCWo_x-UKZ_rDmQa&W9GEyc>> zWi@N-s_JzIN~@W{k37=1n{zyiC$Qri>#Xmm@n-WKdkP|Pxne<+nTy?#a+&8@e@_1(BR zuad0^lYCoRFtte2@-*)qls^1AB06o}05g!z*+fdz%T z|NG4=gP$YpaPxuqi{f)nLZ#>Wl`~fZBK)BSR{s(_h%tNfBsjrcMyH*ogCp)&NWT9} z9k#yI3n%&k^F)9t{Fd9v4Wq~lNO-@Y!PfQXhhoYj#s&7Mnp^(FPTWcd4w%#}^8w%! zS`Zw@eyI*utZ|J}X$_mX8$WU#;9GRrSr=u-W5TsFU0#1Fs%5!5al;qN^DPk%VHNkU z(hFQ5I~U~cm&5=WNo_bKnT-^Z`zRSBI+Yl9C6VGZV5W3VU38g{8^I>H)@XOew`24J zzyrFHrI8*bDzw_Ho=NxOV0%T$@US?5u<7N29J`{zWsZuVUIvNpE|e2WpD8#rjwdywWBF{MeJRZXlzhi}H9fU?JLd38kf2 zj;LV<@IcVCWa}5XFW95(MBRyvtmPPBUNi&~PXPS{6q|n?u;gXJ_%4(kouVMV8~hpq z7!vR#vJaoJFW$$roib2{98&LMV=k%RP79A^4*1bYWdxMoz3Cvvm;+uT!{d(CR@qQ^=`$-#dY&Fy zr!7VAkN3p;C7YH3!gK;&GLt*sD%zfBLMPOwg@BFyh^b*Y98=19-6eBtEMQ(*%|mj4 zAO+iD0;T_W^K;V_mjK1!8!|p?!Q-&H&2gM=I)}*hBv#25kCC{|#>`4XgX``dIZdmy zM*tt-UWo!EW{CQxS0hH$|Ce&;q8_%s#2d?D=D#7 z@q%6C?sSNJx8)D$RY6{e2- z^Rs{9|4EdKPP|0o%ielWhYI|qws5#zi|mXMDWY`mjrRehodnRbl?st$l5T1itrHPI z@rO@pAb=Winu(MXA#Zx~HS+Z>#_<5;6uaUXe);)c4*4n(po(F0Xuj)&zr)qNX-$~k z-;)6Kv*=H4pu6El4g!d8#Z>ipSyhQaY9&4wbhvk*MdTDp46r!>IDnUYS-H$$(3Mo- z=i*fN$9}-g^v5@*0nm$kCCPmau7lq77%jizM)9}ND(l3GNMtX4Aia+dLT&QV^BH`9 z!e;n!#V1-0aY)#iHJx;djB@7_*+*G##6&^y zuf%b-bS|vrQs(wmvskPU4^zNk&Fdt~-@0?23SKi8Rn~jjHtrMAKJ5Z_@YvF1STc3l zL-hf{J7SkA8%7AnXztJBR&`Y7gWhzjZ3+NI^xCT27*YR!LyG?(2jg11QM~{`Y}5H? z<8kg}ujb$A$!quXmSS7-#Zvu`xkuJd>=$}-TA!v{W?rbjGvy#7B2OL=P*1ZE$R1G5 z(EjHVfx&3a;oebXAexrxe z4dc+6%i=1kg(h z!qm@ul(z|mN|x%S?mRLAG2G5;QY0z-K4S`eeq_Mx zZmb?548pclP-`KbYIIqzHC&RC_>5gp@I5wMO5m{7EI%2ro^^J&3RJvi@r9g+_6*4% ze!{%;YfL9^ViJyQ)z^DAXEv=#!!V7YS06O*41AGyg1o%r!B?v0#b4Hl+&lMp0r0Y# za3EH_(^>^jvHi@^c}{$MLWqxz8GH~XHQXN1BFL~aWbI}|RchIcq=; z>RUm@^ZNBaSOS=yZ1mON2HJlxVH8&W^=&t_YQC+kNYE9v&bHe+5rKuCfv$$F2Kmn< zi9I9QSz14U*Vv=a5zOh|mFG;fAK3)7ue2-{`HO!_H_)lGqd)PI<%C6omH)A*xlsdV zWpcI>+qZ2#cxE-E?5Ga>-Z_VnvTh(~0L}P)(deohwAnx~Z=%ydB(J@2+v}ZKsw`vq z@Y}o_lT=qqPY(uQ6LVf^YpQy)R#ep9WsOH4KiGAU5}DKD;1v;(F3#8 z>gwv1=3Nmugj7El7PN*3prhmzVc=^+GK{js=+SGuzfA^nUD@X4q>^LI{IM0K)OiIrK85^_5u5?YY?HLSMec z@7A;BK*YiFyBQMSfFX`CH@W0!R`qT+qc$ZH!Hb^_AaDB?qbmL6sh5PClHGufe zJAlzWHzNhS0os-G)z(7rd}ZT^+5Mgxrt%QPw<0tvTxu9iGv` zqFZtGB>U1Q+icH>qR_}NQuB}-0=KSPlEd~BKBb1dyFsZt5#ZiFAVdo9Ps*fj3G%OJ zrBE^ap`Yz|n- z1^oJdTl|Mghn2`X0WSt9|9cnUzh~M$b0kusPIn3`rtR;l`!c68Oh{236QkBE1xz+s p-x_f^M-3F_z