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 7c8b9051..00000000 Binary files a/test/goldens/goldens/ci/clide_button.png and /dev/null differ diff --git a/test/goldens/goldens/ci/clide_icon.png b/test/goldens/goldens/ci/clide_icon.png deleted file mode 100644 index ed6cf4d2..00000000 Binary files a/test/goldens/goldens/ci/clide_icon.png and /dev/null differ 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 28ea1f36..00000000 Binary files a/test/goldens/goldens/ci/clide_tab_bar.png and /dev/null differ diff --git a/test/goldens/goldens/macos/clide_button.png b/test/goldens/goldens/macos/clide_button.png new file mode 100644 index 00000000..b86dfaf8 Binary files /dev/null and b/test/goldens/goldens/macos/clide_button.png differ diff --git a/test/goldens/goldens/macos/clide_icon.png b/test/goldens/goldens/macos/clide_icon.png new file mode 100644 index 00000000..ee0fa055 Binary files /dev/null and b/test/goldens/goldens/macos/clide_icon.png differ diff --git a/test/goldens/goldens/macos/clide_tab_bar.png b/test/goldens/goldens/macos/clide_tab_bar.png new file mode 100644 index 00000000..4e166e94 Binary files /dev/null and b/test/goldens/goldens/macos/clide_tab_bar.png differ diff --git a/test/helpers/golden_harness.dart b/test/helpers/golden_harness.dart index ee5637f8..6906d6fa 100644 --- a/test/helpers/golden_harness.dart +++ b/test/helpers/golden_harness.dart @@ -2,21 +2,18 @@ import 'package:alchemist/alchemist.dart'; /// Alchemist config shared across all golden tests. /// -/// * CI mode uses the Ahem font (shipped with Flutter's test harness) so -/// goldens render identically on every Linux runner and developer -/// machine. Any drift between platforms points to a real theme-token -/// regression, not a font-rendering fluke. -/// * Local mode keeps developer-machine fonts so you can eyeball -/// renders naturally; the `--update-goldens` workflow still produces -/// CI-valid goldens because CI runs the config below. -AlchemistConfig clideGoldenConfig({bool forceCiMode = false}) { - return AlchemistConfig( +/// Platform goldens only — keyed by OS (`goldens/linux/`, `goldens/macos/`). +/// CI goldens (Ahem font in `goldens/ci/`) are disabled because Skia's +/// geometric anti-aliasing differs between macOS and Linux even with Ahem, +/// producing sub-pixel diffs that fail cross-platform. +AlchemistConfig clideGoldenConfig() { + return const AlchemistConfig( theme: null, // we're not using Material ThemeData platformGoldensConfig: PlatformGoldensConfig( - enabled: !forceCiMode, - ), - ciGoldensConfig: const CiGoldensConfig( enabled: true, ), + ciGoldensConfig: CiGoldensConfig( + enabled: false, + ), ); } diff --git a/test/panes/registry_test.dart b/test/panes/registry_test.dart index e7718ad6..119c5f8f 100644 --- a/test/panes/registry_test.dart +++ b/test/panes/registry_test.dart @@ -1,8 +1,8 @@ /// Unit tests for [PaneRegistry]. /// /// Exercises spawn / list / write / resize / close against the real -/// `ptyc` binary (small enough, and realistic enough, to not be worth -/// mocking). Events are captured via [RecordingEventSink]. +/// NativePty (forkpty via FFI). Events are captured via +/// [RecordingEventSink]. library; import 'dart:async';