diff --git a/.pql/pql-plan.json b/.pql/pql-plan.json index d6f6cbd2..fa6713e7 100644 --- a/.pql/pql-plan.json +++ b/.pql/pql-plan.json @@ -1,5 +1,5 @@ { - "exported_at": "2026-04-26T18:07:54Z", + "exported_at": "2026-05-03T19:24:52Z", "decisions": [ { "id": "D-1", @@ -9,7 +9,7 @@ "status": "active", "date": "2026-04-20", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-3", @@ -19,7 +19,7 @@ "status": "active", "date": "2026-04-20", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-4", @@ -29,7 +29,7 @@ "status": "active", "date": "2026-04-20", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-5", @@ -39,7 +39,7 @@ "status": "active", "date": "2026-04-20", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-6", @@ -49,7 +49,7 @@ "status": "active", "date": "2026-04-20", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-7", @@ -59,7 +59,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-8", @@ -69,7 +69,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-9", @@ -79,7 +79,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-10", @@ -89,7 +89,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-11", @@ -99,7 +99,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-12", @@ -109,7 +109,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-13", @@ -119,7 +119,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-14", @@ -129,7 +129,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-15", @@ -139,7 +139,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/extensions.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-16", @@ -149,7 +149,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/extensions.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-17", @@ -159,7 +159,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/extensions.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-18", @@ -169,7 +169,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/extensions.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-19", @@ -179,7 +179,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/extensions.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-20", @@ -189,7 +189,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/accessibility.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-21", @@ -199,7 +199,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/accessibility.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-22", @@ -209,7 +209,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/accessibility.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-23", @@ -219,7 +219,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/testing.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-24", @@ -229,7 +229,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/testing.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-25", @@ -239,7 +239,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/testing.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-26", @@ -249,7 +249,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/testing.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-27", @@ -259,7 +259,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/testing.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-28", @@ -269,7 +269,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/testing.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-29", @@ -279,7 +279,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/testing.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-30", @@ -289,7 +289,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/testing.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-31", @@ -299,7 +299,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/tooling.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-32", @@ -309,7 +309,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/tooling.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-33", @@ -319,7 +319,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/tooling.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-34", @@ -329,7 +329,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/process.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-35", @@ -339,7 +339,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/process.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-36", @@ -349,7 +349,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/process.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-37", @@ -359,7 +359,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/process.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-38", @@ -369,7 +369,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/process.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-39", @@ -379,7 +379,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/process.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-40", @@ -389,7 +389,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/process.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-41", @@ -399,7 +399,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-42", @@ -409,7 +409,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/tooling.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-43", @@ -419,7 +419,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-44", @@ -429,7 +429,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-45", @@ -439,7 +439,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-46", @@ -449,7 +449,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/extensions.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-47", @@ -459,7 +459,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-48", @@ -469,7 +469,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-49", @@ -479,7 +479,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-50", @@ -489,7 +489,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-51", @@ -499,7 +499,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-52", @@ -509,7 +509,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-53", @@ -519,7 +519,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-54", @@ -529,7 +529,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-55", @@ -539,7 +539,7 @@ "status": "active", "date": "2026-04-23", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-56", @@ -549,7 +549,7 @@ "status": "active", "date": "2026-04-23", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-57", @@ -559,7 +559,7 @@ "status": "active", "date": "2026-04-23", "file_path": "decisions/architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "D-58", @@ -569,7 +569,17 @@ "status": "active", "date": "2026-04-23", "file_path": "decisions/tooling.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" + }, + { + "id": "D-59", + "type": "confirmed", + "domain": "tooling", + "title": "Bundled git via dugite-native", + "status": "active", + "date": "2026-04-25", + "file_path": "decisions/tooling.md", + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-1", @@ -578,7 +588,7 @@ "title": "Authorisation granularity on the IPC socket", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-2", @@ -587,7 +597,7 @@ "title": "Back-pressure on event streams", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-3", @@ -596,7 +606,7 @@ "title": "Event persistence + audit/undo", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-4", @@ -605,7 +615,7 @@ "title": "`.canvas` schema compatibility with Obsidian", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-5", @@ -614,7 +624,7 @@ "title": "IPC wire-format stability + `schema_version:`", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-6", @@ -623,7 +633,7 @@ "title": "Window chrome — native frame vs frameless custom", "status": "resolved", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-7", @@ -632,7 +642,7 @@ "title": "macOS app bundle signing / notarisation", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-8", @@ -641,7 +651,7 @@ "title": "Extension API shape — widgets, subcommands, both?", "status": "open", "file_path": "decisions/questions-extensions.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-9", @@ -650,7 +660,7 @@ "title": "Lua runtime vendoring", "status": "open", "file_path": "decisions/questions-extensions.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-10", @@ -659,7 +669,7 @@ "title": "Extension manifest `schema_version:`", "status": "open", "file_path": "decisions/questions-extensions.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-11", @@ -668,7 +678,7 @@ "title": "Coverage gates — hard thresholds vs soft reporting", "status": "open", "file_path": "decisions/questions-testing.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-12", @@ -677,7 +687,7 @@ "title": "Screen-reader automation (axe-core via Playwright)", "status": "open", "file_path": "decisions/questions-testing.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-13", @@ -686,7 +696,7 @@ "title": "Web production-mode a11y", "status": "open", "file_path": "decisions/questions-accessibility.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-14", @@ -695,7 +705,7 @@ "title": "i18n plurals / gender / date-format tooling", "status": "open", "file_path": "decisions/questions-accessibility.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-15", @@ -704,7 +714,7 @@ "title": "Editor tab — full LSP vs tree-sitter-only highlight", "status": "open", "file_path": "decisions/questions-process.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-16", @@ -713,7 +723,7 @@ "title": "`tree-sitter-dart` grammar maintenance", "status": "open", "file_path": "decisions/questions-process.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-17", @@ -722,7 +732,7 @@ "title": "Icon set growth", "status": "open", "file_path": "decisions/questions-process.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-18", @@ -731,7 +741,7 @@ "title": "Theme hot-reload in release builds", "status": "open", "file_path": "decisions/questions-process.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-19", @@ -740,7 +750,7 @@ "title": "(withdrawn)", "status": "resolved", "file_path": "decisions/questions-process.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-20", @@ -749,7 +759,7 @@ "title": "Kernel DB service — namespaced SQL access?", "status": "open", "file_path": "decisions/questions-process.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-21", @@ -758,7 +768,7 @@ "title": "Pql absorbs planning vs keeps separate", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-22", @@ -767,7 +777,7 @@ "title": "Ticket persistence strategy", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-23", @@ -776,7 +786,7 @@ "title": "SSH-remote development — run clide against a remote workspace", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-25", @@ -785,7 +795,7 @@ "title": "Body text face — mono everywhere vs Josefin Sans UI + mono code", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-26", @@ -794,7 +804,7 @@ "title": "Small screen layout (\u003c 1000px)", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-27", @@ -803,7 +813,7 @@ "title": "Two-editor split", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-28", @@ -812,7 +822,7 @@ "title": "Terminal strip scope — shell only or logs/errors/tests", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-29", @@ -821,7 +831,7 @@ "title": "Branch picker location", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-30", @@ -830,7 +840,7 @@ "title": "Focus behavior when editor is dirty and viewer is peeked", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "Q-31", @@ -839,7 +849,7 @@ "title": "XWayland fallback for frameless — proper Wayland protocol needed", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "R-2", @@ -849,7 +859,7 @@ "status": "active", "date": "2026-04-20", "file_path": "decisions/rejected.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "R-3", @@ -859,7 +869,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "R-4", @@ -869,7 +879,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "R-5", @@ -879,7 +889,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "R-6", @@ -889,7 +899,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "R-7", @@ -899,7 +909,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "R-8", @@ -909,7 +919,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "R-9", @@ -919,7 +929,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "R-10", @@ -929,7 +939,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "R-11", @@ -939,7 +949,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" }, { "id": "R-12", @@ -949,7 +959,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/rejected.md", - "synced_at": "2026-04-25 11:11:15" + "synced_at": "2026-04-28 09:52:13" } ], "decision_refs": [ @@ -1355,6 +1365,18 @@ "ref_type": "references", "note": "**Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-42](#d-42-dependencies-documented-in-licensesyaml)." }, + { + "source_id": "D-59", + "target_id": "D-31", + "ref_type": "references", + "note": "**Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-42](#d-42-dependencies-documented-in-licensesyaml)." + }, + { + "source_id": "D-59", + "target_id": "D-42", + "ref_type": "references", + "note": "**Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-42](#d-42-dependencies-documented-in-licensesyaml)." + }, { "source_id": "D-6", "target_id": "D-1", @@ -1672,7 +1694,7 @@ "priority": "medium", "decision_ref": "D-39", "created_at": "2026-04-22 09:41:51", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-2", @@ -1682,7 +1704,7 @@ "priority": "medium", "decision_ref": "D-7", "created_at": "2026-04-22 11:45:26", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-3", @@ -1692,7 +1714,7 @@ "priority": "medium", "decision_ref": "D-41", "created_at": "2026-04-22 11:45:41", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-4", @@ -1702,7 +1724,7 @@ "priority": "medium", "decision_ref": "D-6", "created_at": "2026-04-22 11:45:41", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-5", @@ -1712,7 +1734,7 @@ "priority": "medium", "decision_ref": "D-13", "created_at": "2026-04-22 11:45:41", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-6", @@ -1722,7 +1744,7 @@ "priority": "medium", "decision_ref": "D-3", "created_at": "2026-04-22 11:45:41", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-7", @@ -1732,7 +1754,7 @@ "priority": "medium", "decision_ref": "D-17", "created_at": "2026-04-22 11:45:41", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-8", @@ -1742,7 +1764,7 @@ "priority": "medium", "decision_ref": "D-15", "created_at": "2026-04-22 11:45:41", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-9", @@ -1753,7 +1775,7 @@ "priority": "medium", "decision_ref": "D-34", "created_at": "2026-04-22 11:47:02", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-10", @@ -1764,7 +1786,7 @@ "priority": "medium", "decision_ref": "D-40", "created_at": "2026-04-22 11:47:02", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-11", @@ -1775,7 +1797,7 @@ "priority": "medium", "decision_ref": "D-39", "created_at": "2026-04-22 11:47:02", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-12", @@ -1786,7 +1808,7 @@ "priority": "medium", "decision_ref": "D-42", "created_at": "2026-04-22 11:47:03", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-13", @@ -1797,7 +1819,7 @@ "priority": "medium", "decision_ref": "D-3", "created_at": "2026-04-22 11:47:18", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-14", @@ -1808,7 +1830,7 @@ "priority": "medium", "decision_ref": "D-3", "created_at": "2026-04-22 11:47:18", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-15", @@ -1819,7 +1841,7 @@ "priority": "medium", "decision_ref": "D-3", "created_at": "2026-04-22 11:47:18", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-16", @@ -1830,7 +1852,7 @@ "priority": "medium", "decision_ref": "D-3", "created_at": "2026-04-22 11:47:18", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-17", @@ -1841,7 +1863,7 @@ "priority": "medium", "decision_ref": "D-32", "created_at": "2026-04-22 13:26:29", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-18", @@ -1851,7 +1873,7 @@ "priority": "medium", "decision_ref": "D-5", "created_at": "2026-04-22 13:26:29", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-19", @@ -1862,7 +1884,7 @@ "priority": "medium", "decision_ref": "D-44", "created_at": "2026-04-22 14:08:40", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-20", @@ -1873,7 +1895,7 @@ "priority": "medium", "decision_ref": "D-45", "created_at": "2026-04-22 14:08:40", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-21", @@ -1884,7 +1906,7 @@ "priority": "medium", "decision_ref": "D-43", "created_at": "2026-04-22 14:08:40", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-22", @@ -1895,7 +1917,7 @@ "priority": "medium", "decision_ref": "D-6", "created_at": "2026-04-22 14:08:40", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-23", @@ -1906,7 +1928,7 @@ "priority": "medium", "decision_ref": "D-6", "created_at": "2026-04-22 14:08:40", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-24", @@ -1917,7 +1939,7 @@ "priority": "medium", "decision_ref": "D-41", "created_at": "2026-04-22 14:08:40", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-25", @@ -1928,7 +1950,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-22 20:14:24", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-26", @@ -1939,7 +1961,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-22 20:14:33", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-27", @@ -1950,7 +1972,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-22 20:14:45", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-28", @@ -1961,7 +1983,7 @@ "status": "in_progress", "priority": "medium", "created_at": "2026-04-22 20:14:52", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-29", @@ -1972,7 +1994,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-22 20:17:26", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-30", @@ -1983,7 +2005,7 @@ "priority": "high", "decision_ref": "D-51", "created_at": "2026-04-22 20:33:53", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-31", @@ -1994,7 +2016,7 @@ "priority": "medium", "decision_ref": "D-52", "created_at": "2026-04-22 20:33:56", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-32", @@ -2005,7 +2027,7 @@ "priority": "medium", "decision_ref": "D-53", "created_at": "2026-04-22 20:33:58", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-33", @@ -2016,7 +2038,7 @@ "priority": "high", "decision_ref": "D-54", "created_at": "2026-04-22 20:34:01", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-34", @@ -2027,7 +2049,7 @@ "priority": "medium", "decision_ref": "D-47", "created_at": "2026-04-22 20:34:03", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-35", @@ -2039,7 +2061,7 @@ "priority": "medium", "decision_ref": "D-49", "created_at": "2026-04-22 20:34:06", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-36", @@ -2051,7 +2073,7 @@ "priority": "low", "decision_ref": "D-50", "created_at": "2026-04-22 20:34:09", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-37", @@ -2063,7 +2085,7 @@ "priority": "medium", "decision_ref": "D-47", "created_at": "2026-04-22 21:03:40", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-38", @@ -2075,7 +2097,7 @@ "priority": "medium", "decision_ref": "D-47", "created_at": "2026-04-22 21:03:40", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-39", @@ -2087,7 +2109,7 @@ "priority": "medium", "decision_ref": "D-47", "created_at": "2026-04-22 21:03:40", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-40", @@ -2099,7 +2121,7 @@ "priority": "low", "decision_ref": "D-47", "created_at": "2026-04-22 21:03:40", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-41", @@ -2111,7 +2133,7 @@ "priority": "low", "decision_ref": "D-47", "created_at": "2026-04-22 21:03:40", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-42", @@ -2123,7 +2145,7 @@ "priority": "low", "decision_ref": "D-48", "created_at": "2026-04-22 21:03:40", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-43", @@ -2135,7 +2157,7 @@ "priority": "high", "decision_ref": "D-41", "created_at": "2026-04-22 22:01:13", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-44", @@ -2145,7 +2167,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:25:26", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-45", @@ -2156,7 +2178,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:27:28", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-46", @@ -2166,7 +2188,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:27:58", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-47", @@ -2177,7 +2199,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:28:43", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-48", @@ -2187,7 +2209,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:30:09", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-49", @@ -2197,7 +2219,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:31:35", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-50", @@ -2207,7 +2229,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:31:48", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-51", @@ -2217,7 +2239,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:32:06", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-52", @@ -2227,7 +2249,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:32:06", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-53", @@ -2237,7 +2259,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:32:06", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-54", @@ -2247,7 +2269,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:32:06", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-55", @@ -2257,7 +2279,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:32:06", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-56", @@ -2267,7 +2289,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:32:06", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-57", @@ -2277,7 +2299,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:32:06", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-58", @@ -2287,7 +2309,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:32:06", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-59", @@ -2297,7 +2319,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:32:06", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-60", @@ -2307,7 +2329,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-23 20:32:06", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-61", @@ -2317,7 +2339,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-24 06:34:16", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-62", @@ -2327,7 +2349,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-24 06:34:16", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-63", @@ -2337,7 +2359,7 @@ "status": "in_progress", "priority": "medium", "created_at": "2026-04-24 06:34:16", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-64", @@ -2347,7 +2369,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-24 06:34:16", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-65", @@ -2357,7 +2379,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-24 06:34:16", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-66", @@ -2367,7 +2389,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-24 06:34:16", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" }, { "id": "T-67", @@ -2377,7 +2399,7 @@ "status": "backlog", "priority": "medium", "created_at": "2026-04-24 06:34:16", - "updated_at": "2026-04-25 11:11:15" + "updated_at": "2026-04-27 16:54:48" } ], "ticket_deps": null, diff --git a/CHANGELOG.md b/CHANGELOG.md index e9e7cdbb..78a31021 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- Backend isolate — all subprocess and file I/O runs in a dedicated + isolate, keeping the merged UI/platform thread on macOS free for + rendering. Communicates via SendPort using the existing IPC protocol. + Two-phase boot: resolve toolchain on spawn, initialize services on + project open. Scheduler ticker only runs while a project is active. + - Toolchain — centralized binary resolution replacing five ad-hoc mechanisms. Resolves git, pql, tmux, ptyc, shell once at boot via background isolate. Status bar reads `toolchain.missing` directly. diff --git a/DECISIONS.md b/DECISIONS.md deleted file mode 100644 index 95c2c43c..00000000 --- a/DECISIONS.md +++ /dev/null @@ -1 +0,0 @@ -See [`decisions/README.md`](decisions/README.md). diff --git a/Makefile b/Makefile index 074a7953..536cdeba 100644 --- a/Makefile +++ b/Makefile @@ -33,9 +33,9 @@ help: ## Show this help. .PHONY: run run: ## Launch the Flutter desktop app. ifeq ($(FLUTTER_OS),linux) - GDK_BACKEND=x11 LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} flutter run -d linux --dart-define=CLIDE_WORKSPACE=$(CURDIR) + GDK_BACKEND=x11 LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} flutter run -d linux --dart-define=CLIDE_PROJECT=$(CURDIR) else - flutter run -d $(FLUTTER_OS) --dart-define=CLIDE_WORKSPACE=$(CURDIR) + flutter run -d $(FLUTTER_OS) --dart-define=CLIDE_PROJECT=$(CURDIR) endif TESTMODE_CATEGORY ?= all @@ -46,7 +46,7 @@ run-testmode: ## Launch ClideTestApp (TESTMODE_CATEGORY=toolchain|ipc|extensions ifeq ($(FLUTTER_OS),linux) @GDK_BACKEND=x11 LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} \ flutter run -d linux \ - --dart-define=CLIDE_WORKSPACE=$(CURDIR) \ + --dart-define=CLIDE_PROJECT=$(CURDIR) \ --dart-define=CLIDE_TESTMODE=$(TESTMODE_CATEGORY) 2>&1 \ | tee /tmp/clide-testmode.log & PID=$$!; \ (sleep $(TESTMODE_TIMEOUT) && kill $$PID 2>/dev/null) & TIMER=$$!; \ @@ -54,7 +54,7 @@ ifeq ($(FLUTTER_OS),linux) grep -q '"failed":0' /tmp/clide-testmode.log else @flutter run -d $(FLUTTER_OS) \ - --dart-define=CLIDE_WORKSPACE=$(CURDIR) \ + --dart-define=CLIDE_PROJECT=$(CURDIR) \ --dart-define=CLIDE_TESTMODE=$(TESTMODE_CATEGORY) 2>&1 \ | tee /tmp/clide-testmode.log & PID=$$!; \ (sleep $(TESTMODE_TIMEOUT) && kill $$PID 2>/dev/null) & TIMER=$$!; \ diff --git a/bin/clide.dart b/bin/clide.dart index 89c24b3a..0a575561 100644 --- a/bin/clide.dart +++ b/bin/clide.dart @@ -149,7 +149,7 @@ Future _runDaemon(List args) async { final events = _ServerEventSink(server); final registry = PaneRegistry(events: events); - registerPaneCommands(dispatcher, registry, toolchain: toolchain); + registerPaneCommands(dispatcher, registry); final files = FilesService.atCwd(events: events); registerFilesCommands(dispatcher, files); diff --git a/docs/macos-pty-problem.md b/docs/macos-pty-problem.md new file mode 100644 index 00000000..21ce48b8 --- /dev/null +++ b/docs/macos-pty-problem.md @@ -0,0 +1,71 @@ +# macOS PTY Problem — Diagnosis Complete + +## Status: Root cause found + +The PTY master fd is valid (`isatty=1`), SCM_RIGHTS transfer is correct, all struct layouts are correct. The problem is **timing**: the reader isolate starts too late and the shell has already exited by the time `read()` is called on the master fd. macOS returns EOF (n=0) immediately when the slave side is closed — unlike Linux which buffers data. + +## The timing sequence (what happens) + +``` +1. Dart: socketpair() +2. Dart: Process.start(ptyc) +3. ptyc: posix_openpt + grantpt + unlockpt + open(slave) +4. ptyc: fork() → child starts zsh +5. ptyc: sendmsg(SCM_RIGHTS, master_fd) ← master fd sent +6. ptyc: printf(ok) + exit (or block if diagnostic) +7. Dart: recvmsg() → receives master_fd ← fd is valid, isatty=1 +8. Dart: PtySession._() constructor +9. Dart: _startReader() → Isolate.spawn() ← ASYNC, does not start immediately +10. ... event loop yields ... +11. Reader isolate starts, calls read(masterFd) +12. But zsh already exited → slave closed → read() returns 0 (EOF) +``` + +The gap between step 9 (Isolate.spawn scheduled) and step 11 (reader actually runs) is where the data is lost. On macOS, the kernel doesn't buffer PTY master data after the slave closes. + +## Why is zsh exiting? + +zsh (`/bin/zsh`) is an interactive shell. It should NOT exit immediately — it should show a prompt and wait for input. But in the test, it does exit. Possible reasons: + +1. **No controlling terminal at spawn time.** ptyc's child does `setsid() + TIOCSCTTY + dup2(slave, 0/1/2)` — this should work. But if the slave fd is already closed or invalid by the time dup2 runs, zsh gets no tty and exits. + +2. **Environment.** The `env` passed to ptyc may be empty or missing `TERM`, `HOME`, `SHELL` etc. Without `TERM`, zsh may fail to initialize and exit. + +3. **The `PTYC_SOCK_FD` inherits open fds.** When ptyc forks, the child inherits ALL open fds (master, slave, socket, exec-failure pipe). ptyc closes master and pipe in the child, but the socket fd stays open. This shouldn't cause an exit, but it's worth checking. + +4. **stdin is connected to the slave.** After dup2(slave, 0), zsh reads from the PTY slave. If Dart hasn't written anything to the master AND there's no PTY echo (because the master isn't being read), zsh might get SIGHUP or detect a broken pipe. + +## Verified facts + +| Test | Result | +|------|--------| +| ptyc compiles on macOS | ✓ | +| socketpair fd inherited by ptyc | ✓ (verified with test binary) | +| ptyc creates PTY and forks | ✓ (`{"ok":true,"pid":N}`) | +| SCM_RIGHTS transfer | ✓ (correct cmsg layout) | +| cmsghdr struct (Dart) | ✓ (CmsghdrDarwin, 12 bytes, correct offsets) | +| msghdr struct (Dart) | ✓ (MsghdrDarwin, correct field sizes) | +| SOL_SOCKET | ✓ (0xffff on macOS) | +| TIOCSWINSZ | ✓ (0x80087467 on macOS) | +| Received fd is a tty | ✓ (isatty=1) | +| Reader isolate starts | ✓ (prints "started, fd=N") | +| Reader gets data | ✗ — EOF immediately (n=0) | +| Keeping ptyc alive helps | ✗ — still EOF | +| close(master) in ptyc is the cause | ✗ — disproven | + +## Next steps + +1. **Investigate why zsh exits immediately.** Add logging to ptyc's child process to verify it reaches execvp. Check if the child gets a signal (SIGHUP, SIGTERM) right after exec. + +2. **Check the environment passed to ptyc.** If `env` is empty, the child shell has no `TERM`, `HOME`, etc. and may exit immediately. + +3. **Try a long-running command** instead of zsh — e.g. `sleep 10` — to rule out shell-specific init failures. + +4. **Consider `forkpty()` on macOS** — eliminates the timing gap entirely. `forkpty()` creates the PTY, forks, and returns the master fd all in one call from the same process. The reader can start before the child is even exec'd. + +## Environment + +- macOS 26.3.1 (Darwin 25.3.0), Apple Silicon (arm64) +- Flutter 3.41.7, Developer ID signed (no sandbox) +- pql 1.4.4, dugite-native git 2.53.0 +- ptyc compiled with: `cc -std=c11 -Wall -Wextra -Wpedantic -Werror -O2 -D_FORTIFY_SOURCE=2 -D_DARWIN_C_SOURCE` diff --git a/docs/pty-proposition.md b/docs/pty-proposition.md new file mode 100644 index 00000000..0e4794eb --- /dev/null +++ b/docs/pty-proposition.md @@ -0,0 +1,57 @@ +# PTY on macOS: A New Diagnostic and Resolution Plan + +## 1. Executive Summary + +Previous attempts to fix the PTY functionality on macOS have failed, even after correcting a deadlock in the Dart code. The core of the problem appears to be a fundamental mismatch in how the C helper (`ptyc`) constructs a control message and how the Dart FFI layer is trying to parse it. + +This document proposes a new, systematic plan to diagnose and resolve this issue by focusing on a key unresolved contradiction: the length of the ancillary data message (`cmsg_len`). + +## 2. The Unresolved Contradiction + +The primary blocker is a discrepancy in the expected length of the `SCM_RIGHTS` control message: + +- **Observed Behavior:** The raw byte dump provided in `docs/macos-pty-problem.md` from the Dart `recvmsg` call shows the `cmsg_len` field as **16 bytes**. +- **Code Analysis:** An analysis of the POSIX `CMSG_LEN` macro, as used in `ptyc.c`, suggests the length should be **20 bytes** on a 64-bit macOS system. + +This contradiction means we cannot be certain about the memory layout of the data we are parsing in Dart. Without resolving this, any attempt to fix the data offset is guesswork. + +## 3. Proposed Investigation and Resolution Plan + +This plan will definitively resolve the `cmsg_len` discrepancy and lead to a correct implementation. + +### Step 1: Instrument `ptyc` to Reveal Ground Truth + +The first step is to get definitive data from the source. We will modify `ptyc.c` to log the exact values it's using. + +- **Action:** Add `fprintf(stderr, ...)` statements in `ptyc.c` right before the `sendmsg` call. +- **Data to Log:** + 1. The calculated value of `CMSG_LEN(sizeof(int))`. + 2. The value of `CMSG_SPACE(sizeof(int))`. + 3. The value of `sizeof(struct cmsghdr)`. +- **Expected Outcome:** This will give us the "ground truth" of the control message structure as constructed by `ptyc` in the actual build environment, resolving the 16-vs-20-byte mystery. + +### Step 2: Correctly Implement the Dart FFI Parser + +With the true `cmsg_len` and memory layout confirmed, we can correctly implement the Dart-side parser. + +- **Action:** Modify `lib/src/pty/ffi/scm_rights.dart`. +- **Logic:** + - If the logged `cmsg_len` from Step 1 confirms there is alignment padding (i.e., data starts at offset 16), the `dataOffset` calculation will be updated to `16`. + - If the logged data shows no padding (i.e., data starts at offset 12), the `dataOffset` will be confirmed as `12`, and we will know the issue lies elsewhere. +- **Expected Outcome:** A Dart parser (`recvFd`) that correctly reads the file descriptor from the ancillary data based on empirical evidence, not theoretical calculation. + +### Step 3: Ensure Asynchronous Operation + +The previously identified deadlock, while not the root cause of this specific failure, is still a critical bug. + +- **Action:** Ensure the fix in `lib/src/pty/session.dart` is applied, where the blocking `scm.recvFd` call is replaced with its asynchronous counterpart, `_recvFdAsync`. +- **Expected Outcome:** The Dart event loop is not blocked during PTY creation, preventing deadlocks. + +### Step 4: Verification + +With the above changes in place, we will verify the complete solution. + +- **Action:** Run the existing test suite via `flutter test test/pty/session_test.dart`. +- **Success Criteria:** All tests in `session_test.dart` must pass, indicating that a PTY can be spawned, its output can be read, and data can be written to it. + +This methodical approach replaces guesswork with a data-driven diagnosis, providing a clear and direct path to resolving the long-standing macOS PTY issue. diff --git a/lib/app.dart b/lib/app.dart index 65603162..bae9dae5 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -478,7 +478,7 @@ class _ProjectSwitcherDropdownState extends State<_ProjectSwitcherDropdown> { focusNode: _focus, onKeyEvent: _onKey, child: Container( - width: 380, + width: 480, constraints: const BoxConstraints(maxHeight: 420), decoration: BoxDecoration( color: tokens.dropdownBackground, @@ -513,10 +513,10 @@ class _ProjectSwitcherDropdownState extends State<_ProjectSwitcherDropdown> { decoration: BoxDecoration(border: Border(top: BorderSide(color: tokens.dividerColor))), child: Column( children: [ - _ActionRow(label: 'Open Local Project', shortcut: 'Ctrl+O', tokens: tokens, onTap: _openFolder), - _ActionRow(label: 'New Window', shortcut: 'Ctrl+Shift+N', tokens: tokens, onTap: _newWindow), + _ActionRow(label: 'Open Local Project', shortcut: Platform.isMacOS ? '⌘O' : 'Ctrl+O', tokens: tokens, onTap: _openFolder), + _ActionRow(label: 'New Window', shortcut: Platform.isMacOS ? '⌘⇧N' : 'Ctrl+Shift+N', tokens: tokens, onTap: _newWindow), if (widget.kernel.project.isOpen) - _ActionRow(label: 'Close Workspace', shortcut: '', tokens: tokens, onTap: _closeWorkspace), + _ActionRow(label: 'Close Project', shortcut: '', tokens: tokens, onTap: _closeWorkspace), ], ), ), diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index d13d6a6f..0bd84edc 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -48,20 +48,26 @@ class _ClaudePaneState extends State { late final Terminal _terminal; StreamSubscription? _eventSub; String? _paneId; + String? _sessionName; String? _error; String _statusLine = 'attaching…'; @override + bool _spawned = false; + void initState() { super.initState(); _terminal = Terminal(maxLines: _maxLines); _terminal.onOutput = _onOutput; _terminal.onResize = _onResize; - WidgetsBinding.instance.addPostFrameCallback((_) => _spawn()); + // Don't spawn here — wait for the first onResize from TerminalView + // so the PTY gets real dimensions, not 80x24 defaults. } @override void dispose() { + _resizeTimer?.cancel(); + _flushTimer?.cancel(); _eventSub?.cancel(); _eventSub = null; final id = _paneId; @@ -78,6 +84,25 @@ class _ClaudePaneState extends State { super.dispose(); } + Future _spawnWhenReady() async { + if (!mounted) return; + final kernel = ClideKernel.of(context); + if (!kernel.project.isOpen) { + // Wait for a project to open before spawning. + final c = Completer(); + late final StreamSubscription sub; + sub = kernel.events.on().listen((_) { + sub.cancel(); + if (!c.isCompleted) c.complete(); + }); + await c.future.timeout(const Duration(seconds: 10), onTimeout: () { + sub.cancel(); + }); + if (!mounted) return; + } + return _spawn(); + } + Future _spawn() async { if (!mounted) return; final ipc = _ipc(); @@ -95,28 +120,32 @@ class _ClaudePaneState extends State { repoRoot = (rootResp.data['path'] as String?) ?? repoRoot; } - final sessionName = widget.isPrimary + _sessionName = widget.isPrimary ? primarySessionName(repoRoot) : secondarySessionName(repoRoot, widget.secondaryIndex!); - // Try tmux-wrapped first (persistence). Fall back to direct claude - // if tmux spawn errors. + // tmux-wrapped session for persistence (D-041). + // -x/-y set the initial window size; without them tmux defaults + // to a huge size when running inside a PTY without a real terminal. + final cols = _terminal.viewWidth; + final rows = _terminal.viewHeight; var argv = [ 'tmux', 'new-session', '-A', '-s', - sessionName, - '--', - 'claude', + _sessionName!, + '-x', '$cols', + '-y', '$rows', ]; + print('[spawn] cols=${_terminal.viewWidth} rows=${_terminal.viewHeight}'); var resp = await ipc.request('pane.spawn', args: { 'argv': argv, 'kind': PaneKind.claude.wire, 'cwd': repoRoot, 'cols': _terminal.viewWidth, 'rows': _terminal.viewHeight, - 'title': sessionName, + 'title': _sessionName, }); if (!resp.ok) { @@ -129,7 +158,7 @@ class _ClaudePaneState extends State { 'cwd': repoRoot, 'cols': _terminal.viewWidth, 'rows': _terminal.viewHeight, - 'title': sessionName, + 'title': _sessionName, }); if (!resp.ok) { setState(() { @@ -139,7 +168,7 @@ class _ClaudePaneState extends State { } setState(() => _statusLine = 'no-tmux · fresh every launch'); } else { - setState(() => _statusLine = 'tmux · $sessionName'); + setState(() => _statusLine = 'tmux · $_sessionName'); } if (!mounted) return; @@ -149,6 +178,16 @@ class _ClaudePaneState extends State { setState(() {}); } + final _outputBuf = StringBuffer(); + Timer? _flushTimer; + + void _flushOutput() { + _flushTimer = null; + if (_outputBuf.isEmpty) return; + _terminal.write(_outputBuf.toString()); + _outputBuf.clear(); + } + void _subscribe() { final kernel = _kernel(); if (kernel == null) return; @@ -158,7 +197,14 @@ class _ClaudePaneState extends State { case 'pane.output': final b64 = e.data['bytes_b64']; if (b64 is String) { - _terminal.write(utf8.decode(base64Decode(b64), allowMalformed: true)); + _outputBuf.write(utf8.decode(base64Decode(b64), allowMalformed: true)); + // Batch all output from the current event loop turn into one + // terminal.write() call. scheduleMicrotask runs after all + // pending events but before the next frame, so split escape + // sequences within the same event batch are reunited. + if (_flushTimer == null) { + _flushTimer = Timer(Duration.zero, _flushOutput); + } } case 'pane.exit': if (widget.isPrimary) { @@ -181,10 +227,29 @@ class _ClaudePaneState extends State { _ipc()?.request('pane.write', args: {'id': id, 'text': text}); } + Timer? _resizeTimer; + void _onResize(int cols, int rows, int _, int __) { - final id = _paneId; - if (id == null) return; - _ipc()?.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows}); + print('[onResize] cols=$cols rows=$rows spawned=$_spawned paneId=$_paneId'); + if (!_spawned) { + // First resize — TerminalView has real dimensions now. + _spawned = true; + _spawnWhenReady(); + return; + } + // Debounce resize — rapid SIGWINCH during window drag corrupts + // the terminal rendering. Wait for the resize to settle. + _resizeTimer?.cancel(); + _resizeTimer = Timer(const Duration(milliseconds: 150), () { + final id = _paneId; + if (id == null) return; + _ipc()?.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows}); + // tmux sizes windows by client, not PTY winsize. Explicitly + // resize the tmux window to match the TerminalView dimensions. + if (_sessionName != null) { + Process.run('tmux', ['resize-window', '-t', _sessionName!, '-x', '$cols', '-y', '$rows']); + } + }); } DaemonClient? _ipc() => _kernel()?.ipc; diff --git a/lib/builtin/decisions/src/extension.dart b/lib/builtin/decisions/src/extension.dart index 087e8d88..1f0afbbe 100644 --- a/lib/builtin/decisions/src/extension.dart +++ b/lib/builtin/decisions/src/extension.dart @@ -32,6 +32,8 @@ class DecisionsExtension extends ClideExtension { icon: PhosphorIcons.lightbulb, build: (_) => DecisionDetailView(initialId: selectedId), )); + ctx.arrangement.setVisible(Slots.contextPanel, true); + ctx.arrangement.setCollapsed(Slots.contextPanel, false); ctx.panels.activateTab(Slots.contextPanel, 'decisions.detail'); }); } diff --git a/lib/builtin/tickets/src/extension.dart b/lib/builtin/tickets/src/extension.dart index ce048d85..1e2ab5c6 100644 --- a/lib/builtin/tickets/src/extension.dart +++ b/lib/builtin/tickets/src/extension.dart @@ -32,6 +32,8 @@ class TicketsExtension extends ClideExtension { icon: PhosphorIcons.ticket, build: (_) => TicketDetailView(initialId: selectedId), )); + ctx.arrangement.setVisible(Slots.contextPanel, true); + ctx.arrangement.setCollapsed(Slots.contextPanel, false); ctx.panels.activateTab(Slots.contextPanel, 'tickets.detail'); }); } diff --git a/lib/kernel/src/backend.dart b/lib/kernel/src/backend.dart new file mode 100644 index 00000000..cfee46ad --- /dev/null +++ b/lib/kernel/src/backend.dart @@ -0,0 +1,140 @@ +/// Manages the backend isolate lifecycle. +/// +/// Two-phase boot: +/// 1. [spawn] — starts the isolate, resolves toolchain (binary checks only). +/// 2. [openWorkspace] — initializes services for a specific project root. +library; + +import 'dart:async'; +import 'dart:isolate'; + +import 'package:clide/kernel/src/backend_entry.dart'; +import 'package:clide/kernel/src/ipc/isolate_client.dart'; +import 'package:clide/kernel/src/toolchain.dart'; + +class Backend { + Backend._({ + required this.client, + required this.toolchain, + required SendPort backendRequestPort, + required Isolate isolate, + required ReceivePort receivePort, + }) : _backendRequestPort = backendRequestPort, + _isolate = isolate, + _receivePort = receivePort; + + final IsolateClient client; + final Toolchain toolchain; + final SendPort _backendRequestPort; + final Isolate _isolate; + final ReceivePort _receivePort; + + Completer? _projectCompleter; + final Map> _validateCompleters = {}; + int _validateId = 0; + + /// Spawn the backend isolate. Returns when the toolchain is resolved. + /// No services are active yet — call [openWorkspace] to activate. + static Future spawn({ + required IsolateClient Function(SendPort backendPort) clientFactory, + String? hintRoot, + }) async { + final receivePort = ReceivePort(); + final completer = Completer(); + + late final IsolateClient client; + late final Isolate isolate; + late final SendPort backendRequestPort; + late final Backend backend; + + receivePort.listen((message) { + if (message is Map) { + final type = message['type'] as String?; + if (type == 'ready') { + backendRequestPort = message['requestPort'] as SendPort; + client = clientFactory(backendRequestPort); + + final tcData = message['toolchain'] as Map; + final toolchain = Toolchain(); + toolchain.applyResolved(ResolvedPaths( + 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(), + )); + + backend = Backend._( + client: client, + toolchain: toolchain, + backendRequestPort: backendRequestPort, + isolate: isolate, + receivePort: receivePort, + ); + + if (!completer.isCompleted) completer.complete(backend); + } else if (type == 'project.validated') { + final id = message['id'] as String; + final root = message['root'] as String?; + final c = backend._validateCompleters.remove(id); + if (c != null && !c.isCompleted) c.complete(root); + } else if (type == 'project.ready') { + // Update toolchain with project-specific paths. + final tcData = message['toolchain'] as Map; + backend.toolchain.applyResolved(ResolvedPaths( + 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(), + )); + backend._projectCompleter?.complete(); + backend._projectCompleter = null; + } else { + // Response or event — forward to the client. + client.handleMessage(message); + } + } + }); + + isolate = await Isolate.spawn( + backendEntry, + BackendBootMessage(frontendPort: receivePort.sendPort, hintRoot: hintRoot), + ); + + return completer.future; + } + + /// Validate a path as a git repo. Returns the repo root or null. + /// Runs git rev-parse in the backend isolate (no main-thread I/O). + Future validateProject(String path) { + final id = '${_validateId++}'; + final c = Completer(); + _validateCompleters[id] = c; + _backendRequestPort.send({ + 'type': 'project.validate', + 'path': path, + 'id': id, + }); + return c.future; + } + + /// Activate a project. The backend (re)initializes all services + /// for the given root directory. Returns when services are ready. + Future openProject(String path) { + _projectCompleter = Completer(); + _backendRequestPort.send({ + 'type': 'project.open', + 'path': path, + }); + return _projectCompleter!.future; + } + + /// Shut down the backend isolate. + void dispose() { + _isolate.kill(priority: Isolate.beforeNextEvent); + _receivePort.close(); + } +} diff --git a/lib/kernel/src/backend_entry.dart b/lib/kernel/src/backend_entry.dart new file mode 100644 index 00000000..193584cf --- /dev/null +++ b/lib/kernel/src/backend_entry.dart @@ -0,0 +1,156 @@ +/// Backend isolate entry point. +/// +/// Two-phase boot: +/// 1. Resolve toolchain (find binaries) → report ready. +/// 2. On `project.open` message → initialize services for the project. +/// +/// The dispatcher only registers command handlers after a project is +/// activated. IPC requests arriving before that get an error response. +library; + +import 'dart:io'; +import 'dart:isolate'; + +import 'package:clide/kernel/src/toolchain.dart'; +import 'package:clide/src/daemon/dispatcher.dart'; +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/git/client.dart'; +import 'package:clide/src/ipc/envelope.dart'; +import 'package:clide/src/ipc/schema_v1.dart'; +import 'package:clide/src/panes/event_sink.dart'; +import 'package:clide/src/panes/registry.dart'; +import 'package:clide/src/pql/client.dart'; + +/// Message sent from main isolate to bootstrap the backend. +class BackendBootMessage { + const BackendBootMessage({required this.frontendPort, this.hintRoot}); + final SendPort frontendPort; + /// Optional path hint for initial toolchain resolution (e.g. CLIDE_PROJECT). + /// Used to find project-local binaries like dugite before a project opens. + final String? hintRoot; +} + +/// Top-level entry point for the backend isolate. +void backendEntry(BackendBootMessage boot) { + final frontendPort = boot.frontendPort; + final requestPort = ReceivePort(); + final eventSink = _IsolateEventSink(frontendPort); + final dispatcher = DaemonDispatcher(); + 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. + final resolveRoot = boot.hintRoot ?? Platform.environment['HOME'] ?? '/tmp'; + toolchain = Toolchain(); + toolchain.applyResolved(resolveToolchainPaths(resolveRoot)); + + // Listen for messages from the frontend. + requestPort.listen((message) async { + if (message is! Map) return; + final type = message['type'] as String?; + + if (type == 'project.validate') { + // Validate a path as a git repo. Runs git rev-parse in the backend + // isolate (safe from the merged thread). Returns the repo root or null. + final path = message['path'] as String; + final id = message['id'] as String; + try { + final r = await Process.run(toolchain.git, ['rev-parse', '--show-toplevel'], + workingDirectory: path, environment: toolchain.gitEnv); + if (r.exitCode == 0) { + final root = (r.stdout as String).trim(); + frontendPort.send({'type': 'project.validated', 'id': id, 'root': root}); + } else { + frontendPort.send({'type': 'project.validated', 'id': id, 'root': null}); + } + } catch (_) { + frontendPort.send({'type': 'project.validated', 'id': id, 'root': null}); + } + } else if (type == 'project.open') { + // Phase 2: (re)initialize services for the given project. + final projectPath = message['path'] as String; + final workDir = Directory(projectPath); + + // Re-resolve toolchain with the actual project root (finds + // dugite in native/dugite/, ptyc in ptyc/bin/, etc.) + toolchain = Toolchain(); + toolchain.applyResolved(resolveToolchainPaths(projectPath)); + + // Clear existing handlers and re-register with new project. + dispatcher.clear(); + + final filesService = FilesService(root: workDir, events: eventSink); + registerFilesCommands(dispatcher, filesService); + + final editorRegistry = EditorRegistry(events: eventSink, workspaceRoot: workDir); + registerEditorCommands(dispatcher, editorRegistry); + + final gitClient = GitClient(toolchain: toolchain, workDir: workDir); + registerGitCommands(dispatcher, gitClient, eventSink); + + final pql = PqlClient(workDir: workDir, toolchain: toolchain); + registerPqlCommands(dispatcher, pql); + + final paneRegistry = PaneRegistry(events: eventSink); + registerPaneCommands(dispatcher, paneRegistry); + + // Tell the frontend the project is active. + frontendPort.send({ + 'type': 'project.ready', + 'path': projectPath, + 'toolchain': _serializeToolchain(toolchain), + }); + } else { + // IPC request — dispatch if we have handlers. + final req = IpcRequest.fromJson(message); + if (dispatcher.isEmpty) { + frontendPort.send(IpcResponse.err( + id: req.id, + error: IpcError( + code: IpcExitCode.toolError, + kind: IpcErrorKind.toolError, + message: 'No project active', + hint: 'Open a project first', + ), + ).toJson()); + } else { + final resp = await dispatcher.dispatch(req); + frontendPort.send(resp.toJson()); + } + } + }); + + // Send ready with toolchain state and request port. + frontendPort.send({ + 'type': 'ready', + 'requestPort': requestPort.sendPort, + 'toolchain': _serializeToolchain(toolchain), + }); +} + +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, + }; + +/// Sends IPC events to the frontend via SendPort. +class _IsolateEventSink implements DaemonEventSink { + _IsolateEventSink(this._port); + final SendPort _port; + + @override + void emit(IpcEvent event) { + _port.send(event.toJson()); + } +} diff --git a/lib/kernel/src/facade.dart b/lib/kernel/src/facade.dart index 393abd84..c647abda 100644 --- a/lib/kernel/src/facade.dart +++ b/lib/kernel/src/facade.dart @@ -102,11 +102,15 @@ class KernelServices { List availableLocales = const [Locale('en', 'US')], String? socketPath, DaemonClient Function(Logger, DaemonBus)? daemonClientFactory, + DaemonClient? isolateClient, bool autoStartDaemonClient = true, Toolchain? toolchain, + Future Function(String path)? onProjectOpen, + Future Function(String path)? onValidateProject, + DaemonBus? sharedBus, }) async { final log = Logger(); - final events = DaemonBus(); + final events = sharedBus ?? DaemonBus(); final messages = MessageBus(); final settings = SettingsStore(appDir: appDir); @@ -147,14 +151,17 @@ class KernelServices { events: events, settings: settings, toolchain: tc, + onProjectOpen: onProjectOpen, + onValidateProject: onValidateProject, ); - final ipc = daemonClientFactory != null - ? daemonClientFactory(log, events) - : DaemonClient( - socketPath: socketPath ?? defaultSocketPath(), - log: log, - events: events, - ); + final ipc = isolateClient + ?? (daemonClientFactory != null + ? daemonClientFactory(log, events) + : DaemonClient( + socketPath: socketPath ?? defaultSocketPath(), + log: log, + events: events, + )); final extensions = ExtensionManager( log: log, events: events, diff --git a/lib/kernel/src/ipc/isolate_client.dart b/lib/kernel/src/ipc/isolate_client.dart new file mode 100644 index 00000000..582d8af7 --- /dev/null +++ b/lib/kernel/src/ipc/isolate_client.dart @@ -0,0 +1,72 @@ +/// IPC client that sends requests to a backend isolate via SendPort. +/// +/// Replaces [InProcessClient] for production use. The backend isolate +/// owns the [DaemonDispatcher] and all subprocess/file-I/O services. +/// Requests and responses travel as serialized Maps over SendPort, +/// reusing the existing IPC protocol (IpcRequest/IpcResponse/IpcEvent). +library; + +import 'dart:async'; +import 'dart:isolate'; + +import 'package:clide/clide.dart'; +import 'package:clide/kernel/src/events/bus.dart'; +import 'package:clide/kernel/src/events/types.dart'; +import 'package:clide/kernel/src/ipc/client.dart'; +import 'package:clide/kernel/src/log.dart'; + +class IsolateClient extends DaemonClient { + IsolateClient({ + required Logger log, + required DaemonBus events, + required SendPort backendPort, + }) : _backendPort = backendPort, + _events = events, + super(socketPath: '', log: log, events: events); + + final SendPort _backendPort; + final DaemonBus _events; + + /// The event bus that receives events from the backend. + DaemonBus get events => _events; + final Map> _pending = {}; + int _nextId = 0; + + /// Called by [Backend] to feed incoming messages from the backend isolate. + void handleMessage(Map msg) { + final type = msg['type'] as String?; + switch (type) { + case 'response': + final resp = IpcResponse.fromJson(msg); + final c = _pending.remove(resp.id); + if (c != null && !c.isCompleted) c.complete(resp); + case 'event': + final evt = IpcEvent.fromJson(msg); + _events.emit(DaemonEvent( + subsystem: evt.subsystem, + kind: evt.kind, + data: evt.data, + ts: evt.timestamp, + )); + } + } + + @override + bool get isConnected => true; + + @override + Future start() async {} + + @override + Future stop() async {} + + @override + Future request(String cmd, {Map args = const {}}) { + final id = '${_nextId++}'; + final req = IpcRequest(id: id, cmd: cmd, args: args); + final c = Completer(); + _pending[id] = c; + _backendPort.send(req.toJson()); + return c.future; + } +} diff --git a/lib/kernel/src/project.dart b/lib/kernel/src/project.dart index 8fb95132..732082f7 100644 --- a/lib/kernel/src/project.dart +++ b/lib/kernel/src/project.dart @@ -49,15 +49,21 @@ class ProjectManager extends ChangeNotifier { required DaemonBus events, required SettingsStore settings, required Toolchain toolchain, + Future Function(String path)? onProjectOpen, + Future Function(String path)? onValidateProject, }) : _log = log, _events = events, _settings = settings, - _toolchain = toolchain; + _toolchain = toolchain, + _onProjectOpen = onProjectOpen, + _onValidateProject = onValidateProject; final Logger _log; final DaemonBus _events; final SettingsStore _settings; final Toolchain _toolchain; + final Future Function(String path)? _onProjectOpen; + final Future Function(String path)? _onValidateProject; Directory? _current; Directory? get current => _current; @@ -81,12 +87,18 @@ class ProjectManager extends ChangeNotifier { } Future open(String path) async { - final root = await resolveWorkspace(path); + final root = await resolveProject(path); if (root == null) { _log.warn('project', 'not a git repo: $path'); return false; } _current = Directory(root); + + // Tell the backend isolate to (re)initialize services for this workspace. + if (_onProjectOpen != null) { + await _onProjectOpen!(root); + } + await _settings.setProjectDir(_current); await _settings.set('app.lastProject', root); @@ -118,7 +130,13 @@ class ProjectManager extends ChangeNotifier { notifyListeners(); } - Future resolveWorkspace(String path) async { + Future resolveProject(String path) async { + // Prefer backend validation (runs in the backend isolate, safe from + // the merged UI thread). Falls back to direct Process.run for tests + // and the CLI binary. + if (_onValidateProject != null) { + return _onValidateProject!(path); + } try { final r = await Process.run(_toolchain.git, ['rev-parse', '--show-toplevel'], workingDirectory: path, environment: _toolchain.gitEnv); if (r.exitCode != 0) return null; diff --git a/lib/kernel/src/scheduler.dart b/lib/kernel/src/scheduler.dart index b3ec5702..66a3354d 100644 --- a/lib/kernel/src/scheduler.dart +++ b/lib/kernel/src/scheduler.dart @@ -36,9 +36,31 @@ class SchedulerService { Isolate? _isolate; ReceivePort? _port; StreamSubscription? _sub; + StreamSubscription? _projectSub; + /// Listen for project lifecycle events. The periodic ticker only runs + /// while a project is open — no wasted cycles on the welcome screen. void start() { - if (_isolate != null) return; + _projectSub = _events.on().listen((_) => _startTicker()); + _events.on().listen((_) => _stopTicker()); + } + + /// Start the periodic ticker and fire an immediate first cycle so + /// all panels refresh without waiting for the first interval. + void _startTicker() { + _stopTicker(); + + // Stagger the initial ticks to avoid a rebuild storm on project open. + var delay = 0; + for (final tier in SchedulerTier.values) { + if (tier == SchedulerTier.midnight) continue; + Timer(Duration(milliseconds: delay), () { + _events.emit(SchedulerTick(tier: tier)); + }); + delay += 500; + } + + // Then start the periodic isolate. _port = ReceivePort(); _sub = _port!.listen((msg) { if (msg is String) { @@ -49,6 +71,15 @@ class SchedulerService { Isolate.spawn(_isolateEntry, _port!.sendPort).then((iso) => _isolate = iso); } + void _stopTicker() { + _sub?.cancel(); + _port?.close(); + _isolate?.kill(priority: Isolate.immediate); + _isolate = null; + _port = null; + _sub = null; + } + static void _isolateEntry(SendPort send) { int lastDay = DateTime.now().day; for (final tier in SchedulerTier.values) { @@ -67,11 +98,7 @@ class SchedulerService { } void dispose() { - _sub?.cancel(); - _port?.close(); - _isolate?.kill(priority: Isolate.immediate); - _isolate = null; - _port = null; - _sub = null; + _projectSub?.cancel(); + _stopTicker(); } } diff --git a/lib/kernel/src/toolchain.dart b/lib/kernel/src/toolchain.dart index b1b5d6b3..901d90fc 100644 --- a/lib/kernel/src/toolchain.dart +++ b/lib/kernel/src/toolchain.dart @@ -10,8 +10,6 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; -import '../../src/pty/env.dart' show expandedPath; - /// Serializable result of tool resolution (crosses isolate boundary). class ResolvedPaths { const ResolvedPaths({ @@ -129,7 +127,7 @@ class Toolchain extends ChangeNotifier { } static String? _findOnPath(String name) { - for (final dir in expandedPath.split(':')) { + for (final dir in _expandedPath().split(':')) { if (dir.isEmpty) continue; final f = File('$dir/$name'); if (f.existsSync()) return f.path; @@ -137,6 +135,23 @@ class Toolchain extends ChangeNotifier { return null; } + /// Build expanded PATH inline — must be self-contained for isolate use. + static String _expandedPath() { + final base = Platform.environment['PATH'] ?? ''; + if (!Platform.isMacOS) return base; + final home = Platform.environment['HOME'] ?? ''; + final extras = [ + if (home.isNotEmpty) '$home/.local/bin', + '/opt/homebrew/bin', + '/opt/homebrew/sbin', + '/usr/local/bin', + ]; + final existing = base.split(':').toSet(); + final missing = extras.where((p) => !existing.contains(p)); + if (missing.isEmpty) return base; + return [...missing, ...existing].join(':'); + } + static String? _firstExisting(List candidates) { for (final c in candidates) { if (File(c).existsSync()) return c; @@ -144,3 +159,72 @@ class Toolchain extends ChangeNotifier { return null; } } + +/// Top-level function for compute/isolate use. Takes a single String +/// argument (the workspace root) and returns a plain-data result. +ResolvedPaths resolveToolchainPaths(String workspaceRoot) { + final dugite = '$workspaceRoot/native/dugite/bin'; + + String? git; + Map? gitEnv; + final dugiteGit = _firstExistingStandalone(['$dugite/git']); + if (dugiteGit != null) { + git = dugiteGit; + final dugiteRoot = File(dugiteGit).parent.parent.path; + gitEnv = { + 'GIT_EXEC_PATH': '$dugiteRoot/libexec/git-core', + 'GIT_TEMPLATE_DIR': '$dugiteRoot/share/git-core/templates', + }; + } else { + git = _findOnPathStandalone('git'); + } + + return ResolvedPaths( + 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, + ); +} + +String? _findOnPathStandalone(String name) { + for (final dir in _expandedPathStandalone().split(':')) { + if (dir.isEmpty) continue; + final f = File('$dir/$name'); + if (f.existsSync()) return f.path; + } + return null; +} + +String? _firstExistingStandalone(List candidates) { + for (final c in candidates) { + if (File(c).existsSync()) return c; + } + return null; +} + +String _expandedPathStandalone() { + final base = Platform.environment['PATH'] ?? ''; + if (!Platform.isMacOS) return base; + final home = Platform.environment['HOME'] ?? ''; + final extras = [ + if (home.isNotEmpty) '$home/.local/bin', + '/opt/homebrew/bin', + '/opt/homebrew/sbin', + '/usr/local/bin', + ]; + final existing = base.split(':').toSet(); + final missing = extras.where((p) => !existing.contains(p)); + if (missing.isEmpty) return base; + return [...missing, ...existing].join(':'); +} diff --git a/lib/main.dart b/lib/main.dart index 1bdbe12d..6cb65e13 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:clide/app.dart'; import 'package:clide/test_app.dart'; import 'package:clide/builtin/canvas/canvas.dart'; @@ -25,13 +23,11 @@ import 'package:clide/builtin/theme_picker/theme_picker.dart'; import 'package:clide/builtin/tickets/tickets.dart'; import 'package:clide/builtin/todos/todos.dart'; import 'package:clide/builtin/welcome/welcome.dart'; -import 'dart:io' show Directory, File, Platform; +import 'dart:io' show Directory, Platform; import 'package:clide/kernel/kernel.dart'; import 'package:clide/kernel/src/ipc/in_process.dart'; import 'package:clide/kernel/src/toolchain.dart'; -import 'package:clide/src/git/client.dart'; -import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart'; import 'package:clide/src/daemon/dispatcher.dart'; import 'package:clide/src/daemon/editor_commands.dart'; import 'package:clide/src/daemon/files_commands.dart'; @@ -39,10 +35,12 @@ 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'; +import 'package:clide/src/git/client.dart'; import 'package:clide/src/ipc/envelope.dart'; import 'package:clide/src/panes/event_sink.dart'; +import 'package:clide/src/panes/registry.dart'; import 'package:clide/src/pql/client.dart'; +import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart' show rootBundle; import 'package:flutter/widgets.dart'; @@ -64,7 +62,14 @@ Future main() async { final appDir = await _resolveAppDir(); final themes = await _loadBundledThemes(); + // Resolve toolchain + boot daemon inline — same as Linux. + // With proper signing (Developer ID), no sandbox or isolate needed. final toolchain = Toolchain(); + if (!kIsWeb) { + const workspace = String.fromEnvironment('CLIDE_PROJECT'); + final root = workspace.isNotEmpty ? workspace : Directory.current.path; + toolchain.applyResolved(resolveToolchainPaths(root)); + } final services = await KernelServices.boot( appDir: appDir, @@ -79,7 +84,7 @@ Future main() async { final filesService = FilesService.atCwd(events: eventSink); final workRoot = filesService.root; final paneRegistry = PaneRegistry(events: eventSink); - registerPaneCommands(dispatcher, paneRegistry, toolchain: toolchain); + registerPaneCommands(dispatcher, paneRegistry); registerFilesCommands(dispatcher, filesService); final editorRegistry = EditorRegistry(events: eventSink, workspaceRoot: workRoot); registerEditorCommands(dispatcher, editorRegistry); @@ -139,19 +144,20 @@ Future main() async { } runApp(ClideApp(services: services)); +} - // Resolve toolchain after the first frame — resolveSymbolicLinksSync() - // blocks the merged UI/platform thread on macOS and prevents rendering - // if called before runApp. - if (!kIsWeb) { - // Defer toolchain resolution. On macOS the merged UI/platform thread - // cannot tolerate synchronous file I/O or isolate spawning during the - // first few frames. A short delay lets Flutter settle first. - Future.delayed(const Duration(seconds: 1), () { - const workspace = String.fromEnvironment('CLIDE_WORKSPACE'); - final root = workspace.isNotEmpty ? workspace : Directory.current.path; - toolchain.applyResolved(Toolchain.resolvePaths(workspaceRoot: root)); - }); +class _BusEventSink implements DaemonEventSink { + _BusEventSink(this._bus); + final DaemonBus _bus; + + @override + void emit(IpcEvent event) { + _bus.emit(DaemonEvent( + subsystem: event.subsystem, + kind: event.kind, + data: event.data, + ts: DateTime.now(), + )); } } @@ -188,21 +194,6 @@ Future> _loadBundledThemes() async { /// registered but not active (the 17 stubs) don't preload — their /// catalogs load lazily on activate in later tiers. -class _BusEventSink implements DaemonEventSink { - _BusEventSink(this._bus); - final DaemonBus _bus; - - @override - void emit(IpcEvent event) { - _bus.emit(DaemonEvent( - subsystem: event.subsystem, - kind: event.kind, - data: event.data, - ts: DateTime.now(), - )); - } -} - const List _tier0Namespaces = [ 'builtin.default-layout', 'builtin.welcome', diff --git a/lib/src/daemon/dispatcher.dart b/lib/src/daemon/dispatcher.dart index fffc1cfc..e6f43680 100644 --- a/lib/src/daemon/dispatcher.dart +++ b/lib/src/daemon/dispatcher.dart @@ -16,6 +16,13 @@ class DaemonDispatcher { _handlers[cmd] = handler; } + /// Remove all registered handlers except ping/version. + void clear() { + _handlers.removeWhere((k, _) => k != 'ping' && k != 'version'); + } + + bool get isEmpty => _handlers.length <= 2; // only ping + version + Future dispatch(IpcRequest req) async { final h = _handlers[req.cmd]; if (h == null) { diff --git a/lib/src/daemon/pane_commands.dart b/lib/src/daemon/pane_commands.dart index b0bc48b8..c852ff10 100644 --- a/lib/src/daemon/pane_commands.dart +++ b/lib/src/daemon/pane_commands.dart @@ -15,11 +15,10 @@ import '../ipc/envelope.dart'; import '../ipc/schema_v1.dart'; import '../panes/pane.dart'; import '../panes/registry.dart'; -import '../../kernel/src/toolchain.dart'; import 'dispatcher.dart'; -void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry, {required Toolchain toolchain}) { - d.register('pane.spawn', (req) => _spawn(req, registry, toolchain)); +void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry) { + d.register('pane.spawn', (req) => _spawn(req, registry)); d.register('pane.list', (req) => _list(req, registry)); d.register('pane.close', (req) => _close(req, registry)); d.register('pane.write', (req) => _write(req, registry)); @@ -48,14 +47,7 @@ IpcResponse _notFound(String id, String message) => IpcResponse.err( ), ); -Future _spawn(IpcRequest req, PaneRegistry registry, Toolchain toolchain) async { - // Wait for toolchain resolution if it hasn't completed yet. - if (!toolchain.resolved) { - await Future.any([ - toolchain.waitForResolution(), - Future.delayed(const Duration(seconds: 5)), - ]); - } +Future _spawn(IpcRequest req, PaneRegistry registry) async { final args = req.args; final rawArgv = args['argv']; if (rawArgv is! List || rawArgv.isEmpty) { @@ -92,7 +84,6 @@ Future _spawn(IpcRequest req, PaneRegistry registry, Toolchain tool cols: (args['cols'] as num?)?.toInt() ?? 80, rows: (args['rows'] as num?)?.toInt() ?? 24, title: args['title'] as String?, - ptycPath: (args['ptyc_path'] as String?) ?? toolchain.ptyc, ); return IpcResponse.ok(id: req.id, data: pane.toJson()); } catch (e) { diff --git a/lib/src/panes/registry.dart b/lib/src/panes/registry.dart index fcef8a4d..7d1ea9c0 100644 --- a/lib/src/panes/registry.dart +++ b/lib/src/panes/registry.dart @@ -8,10 +8,11 @@ library; import 'dart:async'; import 'dart:convert'; +import 'dart:io' show Platform; import 'dart:typed_data'; import '../ipc/envelope.dart'; -import '../pty/session.dart'; +import '../pty/native_pty.dart'; import 'event_sink.dart'; import 'pane.dart'; @@ -20,7 +21,7 @@ class PaneRegistry { final DaemonEventSink events; final Map _panes = {}; - final Map _sessions = {}; + final Map _sessions = {}; final Map> _subs = {}; int _nextId = 1; @@ -42,16 +43,30 @@ class PaneRegistry { int cols = 80, int rows = 24, String? title, - String ptycPath = 'ptyc', }) async { final id = 'p_${_nextId++}'; - final session = await PtySession.spawn( - argv: argv, - cwd: cwd, - env: env, - cols: cols, + final executable = argv.first; + 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. + final fullEnv = { + ...Platform.environment, + 'TERM': 'xterm-256color', + 'COLORTERM': 'truecolor', + 'LANG': 'en_US.UTF-8', + 'LC_ALL': 'en_US.UTF-8', + 'TERMINFO': '/usr/share/terminfo', + if (env != null) ...env, + }; + + final session = NativePty.start( + executable: executable, + arguments: arguments, + columns: cols, rows: rows, - ptycPath: ptycPath, + workingDirectory: cwd, + environment: fullEnv, ); final pane = Pane( id: id, diff --git a/lib/src/pty/ffi/libc.dart b/lib/src/pty/ffi/libc.dart index f63d35a2..678edcb9 100644 --- a/lib/src/pty/ffi/libc.dart +++ b/lib/src/pty/ffi/libc.dart @@ -11,6 +11,7 @@ library; import 'dart:ffi' as ffi; +import 'dart:io' show Platform; import 'package:ffi/ffi.dart' as pkg_ffi; @@ -21,8 +22,8 @@ import 'package:ffi/ffi.dart' as pkg_ffi; const int afUnix = 1; const int sockStream = 1; -const int solSocket = 1; // Linux; macOS = 0xffff -const int scmRights = 1; +final int solSocket = Platform.isMacOS ? 0xffff : 1; +final int scmRights = Platform.isMacOS ? 0x01 : 1; const int fIoNonblock = 0x800; // O_NONBLOCK — 04000 octal const int fGetFl = 3; @@ -133,7 +134,10 @@ final class Msghdr extends ffi.Struct { /// POSIX `struct cmsghdr` prefix. We treat the rest of the control /// buffer as a raw byte region and compute offsets by hand. -final class Cmsghdr extends ffi.Struct { +// On Linux, cmsg_len is size_t (8 bytes on 64-bit). +// On macOS, cmsg_len is socklen_t (4 bytes, always). +// Use platform-specific structs. +final class CmsghdrLinux extends ffi.Struct { @ffi.IntPtr() external int cmsg_len; @ffi.Int32() @@ -142,6 +146,18 @@ final class Cmsghdr extends ffi.Struct { external int cmsg_type; } +final class CmsghdrDarwin extends ffi.Struct { + @ffi.Uint32() + external int cmsg_len; + @ffi.Int32() + external int cmsg_level; + @ffi.Int32() + external int cmsg_type; +} + +// Alias for backward compatibility — callers use Cmsghdr. +typedef Cmsghdr = CmsghdrLinux; + /// POSIX `struct winsize` for `TIOCSWINSZ`. final class Winsize extends ffi.Struct { @ffi.Uint16() diff --git a/lib/src/pty/ffi/scm_rights.dart b/lib/src/pty/ffi/scm_rights.dart index 5499e6ba..98763353 100644 --- a/lib/src/pty/ffi/scm_rights.dart +++ b/lib/src/pty/ffi/scm_rights.dart @@ -6,6 +6,7 @@ library; import 'dart:ffi' as ffi; +import 'dart:io' show Platform; import 'package:ffi/ffi.dart' as pkg_ffi; @@ -58,21 +59,28 @@ int recvFd(int socketFd) { ); } - // Parse the first cmsghdr out of the control buffer. We assume a - // single SCM_RIGHTS cmsg with one int of payload — that's what - // `ptyc` sends and all we ever ask for. - final hdr = control.cast().ref; - if (hdr.cmsg_level != libc.solSocket || hdr.cmsg_type != libc.scmRights) { + // 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=${hdr.cmsg_level} type=${hdr.cmsg_type}', + 'unexpected cmsg level=$cmsgLevel type=$cmsgType', ); } - // CMSG_DATA starts at the first aligned boundary after the cmsghdr. - // On Linux/glibc that's sizeof(cmsghdr) == 16, which is 8-byte - // aligned already. We rely on that layout. - final dataOffset = ffi.sizeOf(); final fdPtr = (control + dataOffset).cast(); return fdPtr.value; } finally { diff --git a/lib/src/pty/native_pty.dart b/lib/src/pty/native_pty.dart new file mode 100644 index 00000000..d113ebb9 --- /dev/null +++ b/lib/src/pty/native_pty.dart @@ -0,0 +1,312 @@ +/// Native PTY via forkpty() — replaces the ptyc helper binary. +/// +/// 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. +/// +/// Based on the pty-spike proof-of-concept. Platform-aware: +/// macOS: forkpty in libSystem (DynamicLibrary.process) +/// Linux: forkpty in libutil.so.1 +library; + +import 'dart:async'; +import 'dart:ffi' as ffi; +import 'dart:io' show File, Platform; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; + +// -- structs ---------------------------------------------------------------- + +final class _Winsize extends ffi.Struct { + @ffi.Uint16() + external int wsRow; + @ffi.Uint16() + external int wsCol; + @ffi.Uint16() + external int wsXpixel; + @ffi.Uint16() + external int wsYpixel; +} + +final class _Pollfd extends ffi.Struct { + @ffi.Int32() + external int fd; + @ffi.Int16() + external int events; + @ffi.Int16() + external int revents; +} + +// -- FFI bindings ----------------------------------------------------------- + +final ffi.DynamicLibrary _dl = _openLib(); + +ffi.DynamicLibrary _openLib() { + if (Platform.isMacOS) return ffi.DynamicLibrary.process(); + // Linux: forkpty lives in libutil + return ffi.DynamicLibrary.open('libutil.so.1'); +} + +final _forkpty = _dl.lookupFunction< + ffi.Int32 Function(ffi.Pointer, ffi.Pointer, + ffi.Pointer, ffi.Pointer<_Winsize>), + int Function(ffi.Pointer, ffi.Pointer, + ffi.Pointer, ffi.Pointer<_Winsize>)>('forkpty'); + +final _execve = _dl.lookupFunction< + ffi.Int32 Function(ffi.Pointer, + ffi.Pointer>, ffi.Pointer>), + int Function(ffi.Pointer, ffi.Pointer>, + ffi.Pointer>)>('execve'); + +final _nativeWrite = ffi.DynamicLibrary.process().lookupFunction< + ffi.IntPtr Function(ffi.Int32, ffi.Pointer, ffi.IntPtr), + int Function(int, ffi.Pointer, int)>('write'); + +final _nativeClose = ffi.DynamicLibrary.process() + .lookupFunction('close'); + +final _ioctl = ffi.DynamicLibrary.process().lookupFunction< + ffi.Int32 Function(ffi.Int32, ffi.UnsignedLong, ffi.Pointer<_Winsize>), + int Function(int, int, ffi.Pointer<_Winsize>)>('ioctl'); + +final _nativeKill = ffi.DynamicLibrary.process().lookupFunction< + ffi.Int32 Function(ffi.Int32, ffi.Int32), int Function(int, int)>('kill'); + +final _waitpid = ffi.DynamicLibrary.process().lookupFunction< + ffi.Int32 Function(ffi.Int32, ffi.Pointer, ffi.Int32), + int Function(int, ffi.Pointer, int)>('waitpid'); + +final _chdir = ffi.DynamicLibrary.process().lookupFunction< + ffi.Int32 Function(ffi.Pointer), + int Function(ffi.Pointer)>('chdir'); + +final _exit_ = ffi.DynamicLibrary.process().lookupFunction< + ffi.Void Function(ffi.Int32), void Function(int)>('_exit'); + +final int _kTiocsWinsz = Platform.isMacOS ? 0x80087467 : 0x5414; +const _kSighup = 1; +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; + final _out = StreamController.broadcast(); + bool _dead = false; + + NativePty._(this._fd, this.pid); + + /// Byte stream of data produced by the child. + Stream get output => _out.stream; + + bool get isClosed => _dead; + + /// Spawn a new PTY running [executable] with [arguments]. + /// + /// [environment] must be the complete environment — it goes straight + /// to execve's envp. Merge Platform.environment before calling. + static NativePty start({ + required String executable, + List arguments = const ['-l'], + required int columns, + required int rows, + String? workingDirectory, + Map environment = const {}, + }) { + // Resolve bare command names via PATH (execve doesn't search PATH). + if (!executable.contains('/')) { + final path = environment['PATH'] ?? Platform.environment['PATH'] ?? ''; + for (final dir in path.split(':')) { + if (dir.isEmpty) continue; + final candidate = '$dir/$executable'; + if (File(candidate).existsSync()) { + executable = candidate; + break; + } + } + } + + // Force-resolve FFI functions that run in the child process. + // Top-level finals are lazy; touching them here ensures the FFI + // trampolines are compiled before fork() clones the process. + final execve = _execve; + final chdir = _chdir; + final exit = _exit_; + + // Allocate ALL native memory before fork. + final shellN = executable.toNativeUtf8(allocator: malloc).cast(); + + final allArgs = [executable, ...arguments]; + final argvN = malloc>(allArgs.length + 1); + for (var i = 0; i < allArgs.length; i++) { + argvN[i] = allArgs[i].toNativeUtf8(allocator: malloc).cast(); + } + argvN[allArgs.length] = ffi.nullptr; + + final envList = environment.entries.toList(); + final envpN = malloc>(envList.length + 1); + for (var i = 0; i < envList.length; i++) { + envpN[i] = '${envList[i].key}=${envList[i].value}' + .toNativeUtf8(allocator: malloc) + .cast(); + } + envpN[envList.length] = ffi.nullptr; + + final wdN = (workingDirectory ?? '/') + .toNativeUtf8(allocator: malloc) + .cast(); + final fdOut = calloc(); + final ws = calloc<_Winsize>() + ..ref.wsRow = rows + ..ref.wsCol = columns; + + // Fork. + final pid = _forkpty(fdOut, ffi.nullptr, ffi.nullptr, ws); + + if (pid == -1) { + _freeAll(shellN, argvN, allArgs.length, envpN, envList.length, wdN, + fdOut, ws); + throw StateError('forkpty() failed'); + } + + if (pid == 0) { + // CHILD — only pre-resolved FFI calls, no Dart heap. + chdir(wdN); + execve(shellN, argvN, envpN); + exit(1); + } + + // PARENT + final fd = fdOut.value; + _freeAll(shellN, argvN, allArgs.length, envpN, envList.length, wdN, + fdOut, ws); + + final pty = NativePty._(fd, pid); + pty._spawnReader(); + return pty; + } + + static void _freeAll( + ffi.Pointer shell, + ffi.Pointer> argv, int argc, + ffi.Pointer> envp, int envc, + ffi.Pointer wd, ffi.Pointer fdOut, ffi.Pointer ws, + ) { + malloc.free(shell); + for (var i = 0; i < argc; i++) malloc.free(argv[i]); + malloc.free(argv); + for (var i = 0; i < envc; i++) malloc.free(envp[i]); + malloc.free(envp); + malloc.free(wd); + calloc.free(fdOut); + calloc.free(ws); + } + + // -- I/O ------------------------------------------------------------------ + + void _spawnReader() async { + final rp = ReceivePort(); + await Isolate.spawn(_readLoop, (rp.sendPort, _fd)); + rp.listen((msg) { + if (msg == null) { + if (!_out.isClosed) _out.close(); + rp.close(); + _reap(); + } else { + if (!_out.isClosed) _out.add(msg as Uint8List); + } + }); + } + + /// Isolate entry — polls then reads until EOF/error/fd-closed. + static void _readLoop((SendPort, int) msg) { + final (port, fd) = msg; + final dl = ffi.DynamicLibrary.process(); + final rd = dl.lookupFunction< + ffi.IntPtr Function(ffi.Int32, ffi.Pointer, ffi.IntPtr), + int Function(int, ffi.Pointer, int)>('read'); + final poll = dl.lookupFunction< + ffi.Int32 Function(ffi.Pointer<_Pollfd>, ffi.Uint32, ffi.Int32), + int Function(ffi.Pointer<_Pollfd>, int, int)>('poll'); + + final buf = malloc(65536); + final pfd = calloc<_Pollfd>(); + pfd.ref.fd = fd; + pfd.ref.events = 0x0001; // POLLIN + + try { + while (true) { + final ready = poll(pfd, 1, 100); + if (ready < 0) break; + if (ready == 0) continue; + if (pfd.ref.revents & 0x0038 != 0 && pfd.ref.revents & 0x0001 == 0) { + break; + } + final n = rd(fd, buf.cast(), 65536); + if (n <= 0) break; + port.send(Uint8List.fromList(buf.asTypedList(n))); + } + } finally { + calloc.free(pfd); + malloc.free(buf); + } + port.send(null); + } + + /// Write bytes to the child's stdin. + int write(List bytes) { + if (_dead || bytes.isEmpty) return 0; + final buf = malloc(bytes.length); + for (var i = 0; i < bytes.length; i++) buf[i] = bytes[i]; + final n = _nativeWrite(_fd, buf.cast(), bytes.length); + malloc.free(buf); + return n; + } + + /// Resize the terminal. + void resize({required int cols, required int rows}) { + if (_dead) return; + final ws = calloc<_Winsize>() + ..ref.wsRow = rows + ..ref.wsCol = cols; + final rc = _ioctl(_fd, _kTiocsWinsz, ws); + calloc.free(ws); + print('[pty-resize] fd=$_fd cols=$cols rows=$rows ioctl=$rc pid=$pid'); + // Explicitly signal the child to re-query its terminal size. + _nativeKill(pid, 28); // SIGWINCH = 28 on macOS/Linux + } + + /// Send a signal to the child. + bool kill([int signal = _kSighup]) { + if (_dead) return false; + return _nativeKill(pid, signal) == 0; + } + + void _reap() { + if (_dead) return; + _dead = true; + final s = calloc(); + _waitpid(pid, s, _kWnohang); + calloc.free(s); + } + + /// Kill the child and release resources. + Future close() async { + if (_dead) return; + _dead = true; + _nativeClose(_fd); + _nativeKill(pid, _kSighup); + _nativeKill(pid, 9); + final s = calloc(); + _waitpid(pid, s, 0); + calloc.free(s); + if (!_out.isClosed) await _out.close(); + } +} diff --git a/lib/src/pty/pty.dart b/lib/src/pty/pty.dart index 9739041d..8ac2e259 100644 --- a/lib/src/pty/pty.dart +++ b/lib/src/pty/pty.dart @@ -1,7 +1,7 @@ -/// PTY subsystem — spawn child processes under a PTY via `ptyc`, +/// PTY subsystem — spawn child processes under a PTY via forkpty(), /// expose their master fd as a byte stream. Desktop IDE's pane model /// (terminal / Claude / future tmux wrappers) rides on this. library; export 'env.dart' show clidePtyEnvDefaults, mergePtyEnv; -export 'session.dart' show PtySession; +export 'native_pty.dart' show NativePty; diff --git a/lib/src/pty/session.dart b/lib/src/pty/session.dart index 89f7905a..457a7f4b 100644 --- a/lib/src/pty/session.dart +++ b/lib/src/pty/session.dart @@ -29,6 +29,12 @@ 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._({ @@ -124,7 +130,9 @@ class PtySession { await proc.stdin.close(); // Receive the master fd over the parent side of the socketpair. - final masterFd = scm.recvFd(parentSock); + // recvFd blocks until ptyc sends — run in a child isolate so the + // calling isolate's event loop stays responsive. + final masterFd = await _recvFdAsync(parentSock); // Apply initial winsize (ptyc already did this, but doing it // again from Dart confirms the wire + gives a place to call it @@ -229,6 +237,27 @@ class PtySession { 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(); + final iso = await Isolate.spawn(_recvFdEntry, _RecvFdArgs(socketFd, port.sendPort)); + final result = await port.first; + iso.kill(priority: Isolate.immediate); + port.close(); + if (result is int) return result; + throw PtyException('recvFd', '$result'); + } + + 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() { diff --git a/lib/test_app.dart b/lib/test_app.dart index b8ebd101..f5e34b62 100644 --- a/lib/test_app.dart +++ b/lib/test_app.dart @@ -14,7 +14,9 @@ library; import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:isolate' show Isolate; +import 'package:flutter/foundation.dart' show compute; import 'package:flutter/services.dart' show rootBundle; import 'package:flutter/widgets.dart'; @@ -23,7 +25,19 @@ import 'builtin/files/files.dart'; import 'builtin/git/git.dart'; import 'builtin/terminal/terminal.dart'; import 'extension/extension.dart' show ClideExtension; +import 'dart:ffi' as ffi; +import 'package:ffi/ffi.dart' as pkg_ffi; import 'kernel/kernel.dart'; +import 'src/pty/ffi/libc.dart' as libc; +import 'kernel/src/events/bus.dart'; +import 'kernel/src/events/types.dart'; +import 'kernel/src/ipc/in_process.dart'; +import 'kernel/src/log.dart'; +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 'kernel/src/toolchain.dart'; import 'src/daemon/dispatcher.dart'; import 'src/ipc/envelope.dart'; @@ -53,7 +67,7 @@ class _ClideTestAppState extends State { } Future _runTests() async { - const workspace = String.fromEnvironment('CLIDE_WORKSPACE'); + const workspace = String.fromEnvironment('CLIDE_PROJECT'); const category = String.fromEnvironment('CLIDE_TESTMODE'); final workDir = workspace.isNotEmpty ? workspace : Directory.current.path; @@ -61,6 +75,7 @@ class _ClideTestAppState extends State { final runToolchain = runAll || category == 'toolchain'; final runIpc = runAll || category == 'ipc'; final runExtensions = runAll || category == 'extensions'; + final runTerminal = runAll || category == 'terminal'; print('[testmode] === ClideTestApp starting ==='); print('[testmode] workspace=$workDir'); @@ -75,6 +90,7 @@ class _ClideTestAppState extends State { if (runToolchain) await _runToolchainTests(tc, workDir); if (runIpc) await _runIpcTests(workDir); if (runExtensions) await _runExtensionTests(workDir, tc); + if (runTerminal) await _runTerminalTests(tc, workDir); final passed = _results.where((r) => r.ok).length; final failed = _results.where((r) => !r.ok).length; @@ -134,6 +150,55 @@ class _ClideTestAppState extends State { _log('gitEnv', '${tc.gitEnv}'); print('[testmode]'); + + // Boot sequence simulation tests + print('[testmode] --- boot sequence ---'); + + await _testAsync('compute(resolveToolchainPaths)', () async { + final paths = await compute(resolveToolchainPaths, workDir); + return 'git=${paths.git} pql=${paths.pql}'; + }); + + await _testAsync('Isolate.run(resolveToolchainPaths)', () async { + final paths = await Isolate.run(() => resolveToolchainPaths(workDir)); + return 'git=${paths.git} pql=${paths.pql}'; + }); + + await _testAsync('git rev-parse (project.open sim)', () async { + final r = await Process.run(tc.git, ['rev-parse', '--show-toplevel'], + workingDirectory: workDir, environment: tc.gitEnv); + return 'exit=${r.exitCode} ${(r.stdout as String).trim()}'; + }); + + await _testAsync('sequential git calls', () async { + final r1 = await Process.run(tc.git, ['rev-parse', '--show-toplevel'], + workingDirectory: workDir, environment: tc.gitEnv); + final r2 = await Process.run(tc.git, ['rev-parse', '--abbrev-ref', 'HEAD'], + workingDirectory: workDir, environment: tc.gitEnv); + return 'root=${(r1.stdout as String).trim()} branch=${(r2.stdout as String).trim()}'; + }); + + await _testAsync('compute + immediate Process.run', () async { + final paths = await compute(resolveToolchainPaths, workDir); + final tc2 = Toolchain(); + tc2.applyResolved(paths); + final r = await Process.run(tc2.git, ['rev-parse', '--show-toplevel'], + workingDirectory: workDir, environment: tc2.gitEnv); + 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}'; + }); + + print('[testmode]'); } // -- ipc category --------------------------------------------------------- @@ -260,6 +325,162 @@ class _ClideTestAppState extends State { print('[testmode]'); } + // -- terminal category ---------------------------------------------------- + + Future _runTerminalTests(Toolchain tc, String workDir) async { + print('[testmode] --- terminal ---'); + + // Test PTY via InProcessClient — same path as the real app. + await _testAsync('pane.spawn via IPC', () async { + final dispatcher = DaemonDispatcher(); + final bus = DaemonBus(); + final eventSink = _TestEventSink(bus); + final workDir2 = Directory(workDir); + final paneRegistry = PaneRegistry(events: eventSink); + registerPaneCommands(dispatcher, paneRegistry); + final ipc = InProcessClient(log: Logger(), events: bus, dispatcher: dispatcher); + + // Spawn a pane running /bin/echo. + // Use interactive shell — fast-exiting commands lose output on macOS + // because the slave closes before we can read the master. + final spawnResp = await ipc.request('pane.spawn', args: { + 'argv': [tc.shell], + 'kind': 'terminal', + }); + print('[testmode] spawn: ok=${spawnResp.ok} ${spawnResp.ok ? spawnResp.data : spawnResp.error?.message}'); + if (!spawnResp.ok) { + return 'spawn failed: ${spawnResp.error?.message}'; + } + final paneId = spawnResp.data['id'] as String; + + // Collect pane.output events. + final outputParts = []; + int eventCount = 0; + final sub = bus.on().listen((e) { + eventCount++; + if (e.subsystem == 'pane' && e.kind == 'pane.output' && e.data['id'] == paneId) { + final b64 = e.data['bytes_b64'] as String?; + if (b64 != null) outputParts.add(utf8.decode(base64Decode(b64), allowMalformed: true)); + } + }); + await Future.delayed(const Duration(seconds: 3)); + print('[testmode] events=$eventCount output_parts=${outputParts.length} bytes=${outputParts.join().length}'); + if (outputParts.isNotEmpty) { + print('[testmode] first output: ${outputParts.first.substring(0, outputParts.first.length.clamp(0, 80))}'); + } + await sub.cancel(); + paneRegistry.shutdown(); + + final output = outputParts.join(); + return output.isNotEmpty ? 'got ${output.length} chars' : 'no output (0 chars)'; + }); + + // Test: does Dart's Process.start inherit socket fds on macOS? + await _testAsync('fd inheritance check', () async { + final sv = pkg_ffi.calloc(2); + libc.socketpair(1, 1, 0, sv); // AF_UNIX, SOCK_STREAM + final parent = sv[0]; + final child = sv[1]; + pkg_ffi.calloc.free(sv); + final proc = await Process.start('/tmp/checkfd', [], + environment: {...Platform.environment, 'PTYC_SOCK_FD': '$child'}); + final stderr = await proc.stderr.transform(utf8.decoder).join(); + final exit = await proc.exitCode; + libc.close(parent); + libc.close(child); + return 'exit=$exit stderr=${stderr.trim()}'; + }); + + // 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, + ); + print('[testmode] session pid=${session.pid} masterFd exists'); + final bytes = []; + final done = Completer(); + session.output.listen( + (chunk) { + bytes.addAll(chunk); + print('[testmode] got ${chunk.length} bytes'); + }, + onDone: () { + print('[testmode] stream done'); + if (!done.isCompleted) done.complete(); + }, + onError: (e) => print('[testmode] stream error: $e'), + ); + await done.future.timeout(const Duration(seconds: 5), onTimeout: () { + print('[testmode] 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 + + print('[testmode]'); + } + // -- helpers -------------------------------------------------------------- void _log(String key, String value) { @@ -279,6 +500,17 @@ class _ClideTestAppState extends State { setState(() => _results.add(r)); } + Future _testAsync(String label, Future Function() fn) async { + try { + final result = await fn().timeout(const Duration(seconds: 10)); + _addResult(label, true, result); + } on TimeoutException { + _addResult(label, false, 'TIMEOUT (10s)'); + } catch (e) { + _addResult(label, false, '$e'); + } + } + Future _testExec(String label, String bin, List args, String workDir, {Map? env}) async { try { final r = await Process.run(bin, args, workingDirectory: workDir, environment: env) @@ -341,6 +573,21 @@ class _ClideTestAppState extends State { } } +class _TestEventSink implements DaemonEventSink { + _TestEventSink(this._bus); + final DaemonBus _bus; + + @override + void emit(IpcEvent event) { + _bus.emit(DaemonEvent( + subsystem: event.subsystem, + kind: event.kind, + data: event.data, + ts: DateTime.now(), + )); + } +} + class _TestResult { const _TestResult({required this.name, required this.detail, required this.ok, required this.output}); final String name; diff --git a/lib/widgets/src/clide_pty_view.dart b/lib/widgets/src/clide_pty_view.dart index 0fbc609f..8097bef1 100644 --- a/lib/widgets/src/clide_pty_view.dart +++ b/lib/widgets/src/clide_pty_view.dart @@ -54,7 +54,7 @@ class ClidePtyView extends StatelessWidget { fontFamily: clideMonoFamily, fontFamilyFallback: clideMonoFamilyFallback, ), - padding: const EdgeInsets.all(8), + padding: EdgeInsets.zero, backgroundOpacity: 1, cursorType: TerminalCursorType.block, ), diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 993c04b7..bfa11c96 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -525,7 +525,8 @@ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_IDENTITY = "Apple Development"; + DEVELOPMENT_TEAM = 54XXM3ZQTX; COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; @@ -601,7 +602,8 @@ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_IDENTITY = "Apple Development"; + DEVELOPMENT_TEAM = 54XXM3ZQTX; COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; @@ -657,7 +659,8 @@ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_IDENTITY = "Apple Development"; + DEVELOPMENT_TEAM = 54XXM3ZQTX; COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib index 06afcd8e..d8976328 100644 --- a/macos/Runner/Base.lproj/MainMenu.xib +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -332,7 +332,7 @@ - + diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index d2fa9a4c..461123ca 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -2,30 +2,9 @@ - com.apple.security.app-sandbox - com.apple.security.cs.allow-jit - com.apple.security.network.server + com.apple.security.cs.disable-library-validation - com.apple.security.network.client - - com.apple.security.files.user-selected.read-write - - com.apple.security.temporary-exception.sbpl - - (allow process-exec* (literal "/bin/zsh")) - (allow process-exec* (literal "/usr/bin/which")) - (allow process-exec* (subpath "/Users/jeroenschweitzer/.local/bin")) - (allow process-exec* (subpath "/Users/jeroenschweitzer/Projects/clide/ptyc/bin")) - (allow process-exec* (subpath "/Users/jeroenschweitzer/Projects/clide/native/dugite")) - (allow process-fork) - (allow file-read* file-write* (subpath "/Users/jeroenschweitzer/Projects")) - (allow file-read* file-write* (subpath "/Users/jeroenschweitzer/projects")) - (allow file-read* (subpath "/opt/homebrew")) - (allow file-read* (subpath "/Users/jeroenschweitzer/.local")) - (allow file-read* (subpath "/Users/jeroenschweitzer/.pql")) - (allow file-read* file-write* (subpath "/private/tmp")) - diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 852fa1a4..8cc185af 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -2,7 +2,7 @@ - com.apple.security.app-sandbox + com.apple.security.cs.disable-library-validation diff --git a/test/daemon/pane_commands_test.dart b/test/daemon/pane_commands_test.dart index ea96e8d8..9c98c081 100644 --- a/test/daemon/pane_commands_test.dart +++ b/test/daemon/pane_commands_test.dart @@ -28,7 +28,7 @@ void main() { final sink = RecordingEventSink(); registry = PaneRegistry(events: sink); dispatcher = DaemonDispatcher(); - registerPaneCommands(dispatcher, registry, toolchain: toolchain); + registerPaneCommands(dispatcher, registry); }); tearDown(() => registry.shutdown()); diff --git a/test/panes/registry_test.dart b/test/panes/registry_test.dart index 2bbc7848..aff3d35d 100644 --- a/test/panes/registry_test.dart +++ b/test/panes/registry_test.dart @@ -35,7 +35,6 @@ void main() { final pane = await registry.spawn( kind: PaneKind.terminal, argv: const ['/bin/echo', 'hi'], - ptycPath: ptycPath, ); expect(pane.id, startsWith('p_')); @@ -50,7 +49,6 @@ void main() { await registry.spawn( kind: PaneKind.terminal, argv: const ['/bin/echo', 'hello-panes'], - ptycPath: ptycPath, ); // /bin/echo closes its pty quickly. Wait briefly for output + @@ -73,7 +71,6 @@ void main() { final pane = await registry.spawn( kind: PaneKind.terminal, argv: const ['/bin/cat'], - ptycPath: ptycPath, ); final writeCount = registry.write(pane.id, utf8.encode('abc')); @@ -90,7 +87,6 @@ void main() { final pane = await registry.spawn( kind: PaneKind.terminal, argv: const ['/bin/cat'], - ptycPath: ptycPath, ); await registry.close(pane.id); @@ -109,7 +105,6 @@ void main() { final pane = await registry.spawn( kind: PaneKind.claude, argv: const ['/bin/sh', '-c', 'exit 0'], - ptycPath: ptycPath, ); expect(pane.kind, PaneKind.claude); expect(pane.toJson()['kind'], 'claude'); diff --git a/test/pty/session_test.dart b/test/pty/session_test.dart index 18a8464b..8f8711da 100644 --- a/test/pty/session_test.dart +++ b/test/pty/session_test.dart @@ -1,114 +1,94 @@ -/// `PtySession` smoke tests. +/// NativePty smoke tests. /// -/// Exercises the real `ptyc` binary end-to-end: socketpair → spawn → -/// SCM_RIGHTS fd receive → child output through the reader isolate. -/// Linux + macOS only; skipped elsewhere. +/// Exercises forkpty() end-to-end: spawn → child output through the +/// reader isolate. Linux + macOS only; skipped elsewhere. library; import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:clide/src/pty/pty.dart'; +import 'package:clide/src/pty/native_pty.dart'; import 'package:test/test.dart'; void main() { - if (!Platform.isLinux && !Platform.isMacOS) { - return; // POSIX-only wrapper for now. - } + if (!Platform.isLinux && !Platform.isMacOS) return; - final ptycPath = _resolvePtyc(); + final shell = Platform.environment['SHELL'] ?? '/bin/zsh'; - group('PtySession', () { - test('spawns /bin/echo and reads its output', () async { - final s = await PtySession.spawn( - argv: const ['/bin/echo', 'hello-pty'], - ptycPath: ptycPath, + group('NativePty', () { + test('spawns shell -c echo and reads output', () async { + final s = NativePty.start( + executable: shell, + arguments: ['-l', '-c', 'echo hello-pty'], + columns: 80, + rows: 24, + workingDirectory: Platform.environment['HOME'] ?? '/', + environment: { + ...Platform.environment, + 'TERM': 'xterm-256color', + }, ); addTearDown(s.close); final buf = StringBuffer(); - final sub = s.output.listen((bytes) => buf.write(utf8.decode(bytes))); - try { - // echo exits quickly; give the reader up to 2s to see its - // output before we assert. - await Future.delayed(const Duration(milliseconds: 500)); - for (var i = 0; i < 20 && !buf.toString().contains('hello-pty'); i++) { - await Future.delayed(const Duration(milliseconds: 100)); - } - } finally { - await sub.cancel(); - } + s.output.listen((bytes) => buf.write(utf8.decode(bytes, allowMalformed: true))); + // Shell exits quickly; give reader up to 3s. + for (var i = 0; i < 30 && !buf.toString().contains('hello-pty'); i++) { + await Future.delayed(const Duration(milliseconds: 100)); + } expect(buf.toString(), contains('hello-pty')); - expect(s.pid, greaterThan(0)); }); - test('write round-trips through /bin/cat', () async { - final s = await PtySession.spawn( - argv: const ['/bin/cat'], - ptycPath: ptycPath, - ); - addTearDown(s.close); - - final got = Completer(); - final buf = StringBuffer(); - s.output.listen((bytes) { - buf.write(utf8.decode(bytes)); - if (buf.toString().contains('echo-me')) { - if (!got.isCompleted) got.complete(buf.toString()); - } - }); - - // Give the PTY a moment to be ready. - await Future.delayed(const Duration(milliseconds: 100)); - s.write(utf8.encode('echo-me\n')); - - final out = await got.future.timeout(const Duration(seconds: 3)); - expect(out, contains('echo-me')); - }); - - test('COLORTERM truecolor propagates to the child', () async { - // `/usr/bin/env` prints the child's environment. We should see - // COLORTERM=truecolor because clidePtyEnvDefaults sets it. - final s = await PtySession.spawn( - argv: const ['/usr/bin/env'], - ptycPath: ptycPath, + test('write sends keystrokes to child', () async { + final s = NativePty.start( + executable: shell, + arguments: ['-l'], + columns: 80, + rows: 24, + workingDirectory: Platform.environment['HOME'] ?? '/', + environment: { + ...Platform.environment, + 'TERM': 'xterm-256color', + }, ); addTearDown(s.close); final buf = StringBuffer(); - final sub = s.output.listen((bytes) => buf.write(utf8.decode(bytes))); - try { - for (var i = 0; i < 20; i++) { - if (buf.toString().contains('COLORTERM=truecolor')) break; - await Future.delayed(const Duration(milliseconds: 100)); - } - } finally { - await sub.cancel(); + s.output.listen((bytes) => buf.write(utf8.decode(bytes, allowMalformed: true))); + + // Wait for prompt. + await Future.delayed(const Duration(seconds: 1)); + + // Type a command. + s.write(utf8.encode('echo write-test-ok\n')); + + for (var i = 0; i < 30 && !buf.toString().contains('write-test-ok'); i++) { + await Future.delayed(const Duration(milliseconds: 100)); } - - expect(buf.toString(), contains('COLORTERM=truecolor')); - expect(buf.toString(), contains('TERM=xterm-256color')); + expect(buf.toString(), contains('write-test-ok')); }); - test('close is idempotent and stops the stream', () async { - final s = await PtySession.spawn( - argv: const ['/bin/cat'], - ptycPath: ptycPath, + test('close kills child and closes output', () async { + final s = NativePty.start( + executable: shell, + arguments: ['-l'], + columns: 80, + rows: 24, + workingDirectory: Platform.environment['HOME'] ?? '/', + environment: { + ...Platform.environment, + 'TERM': 'xterm-256color', + }, ); - expect(s.isClosed, isFalse); + + final done = Completer(); + s.output.listen((_) {}, onDone: () => done.complete()); + await s.close(); + await done.future.timeout(const Duration(seconds: 3)); expect(s.isClosed, isTrue); - await s.close(); // second call should not throw }); }); } - -/// Locate the `ptyc` binary relative to the repo root, falling back to -/// PATH. Lets tests run in fresh clones before anyone's touched PATH. -String _resolvePtyc() { - final devPath = File('ptyc/bin/ptyc'); - if (devPath.existsSync()) return devPath.absolute.path; - return 'ptyc'; -}