remove dissolved daemon, retire ptyc, fix golden cross-platform

Complete three overdue cleanups discovered during macOS health check:

D-56 daemon dissolution: delete bin/clide.dart, DaemonServer,
and orphaned tests (test/cli/, subprocess_test, in_process_test).
Update stale "clide --daemon" references in i18n catalogs, error
messages, editor_commands, CI scripts, and decision records.

ptyc retirement: delete ptyc/ source tree, PtySession, scm_rights.
Remove from Toolchain resolution, ToolCheck gate, backend
serialization, testmode harness, Makefile, CI, and sandbox
entitlements. PTY spawning uses NativePty (Dart FFI forkpty) since
the terminal was absorbed in-tree. D-5 amended.

Golden tests: wire the existing but never-applied clideGoldenConfig
via flutter_test_config.dart. Disable CI goldens (Skia anti-aliasing
differs between macOS/Linux even with Ahem). Keep platform-keyed
goldens only — goldens/linux/ and goldens/macos/ each run on their
own OS.

Test suite: 826 pass, 0 fail on macOS (was 829 pass, 11 fail).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeroen Schweitzer
2026-05-07 18:40:01 +02:00
co-authored by Claude Opus 4.6
parent 6b7290dc42
commit a6eca2561b
51 changed files with 265 additions and 2719 deletions
-4
View File
@@ -26,10 +26,6 @@ tools/ui/test-results/
tools/ui/playwright-report/ tools/ui/playwright-report/
tools/ui/.serve.pid tools/ui/.serve.pid
# -- ptyc (C supporter tool) --------------------------------------------
/ptyc/bin/
/ptyc/*.o
# -- dugite-native (bundled git, downloaded at build time) --------------- # -- dugite-native (bundled git, downloaded at build time) ---------------
/native/dugite/ /native/dugite/
+175 -175
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -16,6 +16,26 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
## [Unreleased] ## [Unreleased]
### Removed
- **`bin/clide.dart` + `DaemonServer`** — completing the D-56 dissolution.
The separate daemon process was dissolved on 2026-04-23 but the entry
point and socket server class were never actually deleted. Gone now,
along with orphaned tests (`test/cli/`, `test/daemon/subprocess_test`,
`test/daemon/in_process_test`), stale i18n strings, and "start
`clide --daemon`" error messages.
- **`ptyc/` source tree + `PtySession` + `scm_rights.dart`** — PTY
spawning migrated to Dart FFI `forkpty()` (`NativePty`) but the old
C helper and its Dart wiring were never cleaned up. Removed from
toolchain resolution, `ToolCheck` gate, backend serialization,
testmode harness, CI scripts, Makefile, and sandbox entitlements.
D-5 amended to record the retirement.
- CI golden images (`test/goldens/goldens/ci/`) — Skia anti-aliasing
of geometric shapes differs between macOS and Linux even with the
Ahem font, so a single set of CI goldens can't serve both platforms.
Replaced with platform-keyed goldens (`goldens/linux/`,
`goldens/macos/`), each only compared on its own OS.
### Added ### Added
- Pre-push coverage gate — `make push-check` (and the - Pre-push coverage gate — `make push-check` (and the
+7 -10
View File
@@ -4,11 +4,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What clide is ## What clide is
An IDE for Claude Code CLI. Single Flutter package at the repo root, plus small native supporter tools where Dart can't reach. An IDE for Claude Code CLI. Single Flutter package at the repo root.
- **`lib/`** — all Dart code. Subsystem handlers (`lib/src/daemon/`, `lib/src/pty/`, `lib/src/ipc/`, `lib/src/git/`, `lib/src/pql/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), and the extension framework (`lib/extension/`). The Flutter app hosts the IPC server in-process (D-56). - **`lib/`** — all Dart code. Subsystem handlers (`lib/src/daemon/`, `lib/src/pty/`, `lib/src/ipc/`, `lib/src/git/`, `lib/src/pql/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), and the extension framework (`lib/extension/`). The Flutter app hosts the IPC server in-process (D-56). PTY spawning uses Dart FFI `forkpty()` directly.
- **[`pql`](https://github.com/postmeridiem/pql)** — external supporter tool. Clide wraps it for every query surface; never re-implements it. - **[`pql`](https://github.com/postmeridiem/pql)** — external supporter tool. Clide wraps it for every query surface; never re-implements it.
- **`ptyc/`** — small C supporter tool, peer of pql. Spawns a PTY + child and hands the master fd back over `SCM_RIGHTS`. Clide shells out to it for every pane (shell, tmux, claude, LSP, debug adapter).
tmux owns Claude session persistence (D-41) — the app re-attaches on restart via `tmux new-session -A`. Native rendering — markdown, canvas, graph — is Dart/Flutter (`CustomPaint` + widgets), not third-party packages. tmux owns Claude session persistence (D-41) — the app re-attaches on restart via `tmux new-session -A`. Native rendering — markdown, canvas, graph — is Dart/Flutter (`CustomPaint` + widgets), not third-party packages.
@@ -19,10 +18,10 @@ Design doc: [`docs/initial-plan.md`](docs/initial-plan.md). Decisions: [`decisio
These are load-bearing. Violating any means the design is wrong, not the rule. These are load-bearing. Violating any means the design is wrong, not the rule.
- **Flutter desktop is the host. No Electron, ever.** Web target may work as a happy accident — don't compromise desktop fidelity for it. If we ship a web build at all, prefer Flutter's **WebAssembly (CanvasKit/Skwasm) compile** over the JS/HTML renderer. `xterm.dart` is the terminal renderer; markdown, canvas, graph are custom `CustomPaint`/widget components. - **Flutter desktop is the host. No Electron, ever.** Web target may work as a happy accident — don't compromise desktop fidelity for it. If we ship a web build at all, prefer Flutter's **WebAssembly (CanvasKit/Skwasm) compile** over the JS/HTML renderer. `xterm.dart` is the terminal renderer; markdown, canvas, graph are custom `CustomPaint`/widget components.
- **Single process.** The Flutter app hosts everything in-process: IPC server, subsystem handlers (pane, files, editor, git, pql), extensions. No separate daemon binary (D-56 dissolved it). The CLI surface for Claude is a thin C client (ptyc peer). - **Single process.** The Flutter app hosts everything in-process: IPC server, subsystem handlers (pane, files, editor, git, pql), extensions. No separate daemon binary (D-56 dissolved it).
- **CLI-first, not MCP.** Claude talks via Bash (`clide ...`), matching pql's contract. See [`D-1`](decisions/architecture.md#d-1-cli-first-not-mcp). - **CLI-first, not MCP.** Claude talks via Bash (`clide ...`), matching pql's contract. See [`D-1`](decisions/architecture.md#d-1-cli-first-not-mcp).
- **Dart is the core; native supporter tools fill specific gaps.** `ptyc` (C) for PTY spawning. `pql` (Go) for queries. No second "core language." See [`D-5`](decisions/architecture.md#d-5-dart-core-sidecar-dissolved-ptyc-as-pql-peer) (amended by D-56). - **Dart is the core; pql fills the query gap.** PTY spawning is native Dart FFI (`forkpty`). `pql` (Go) handles vault queries. No second "core language." See [`D-5`](decisions/architecture.md#d-5-dart-core-sidecar-dissolved-ptyc-as-pql-peer) (amended by D-56).
- **Own the rendering stack.** PTY (via `ptyc`), markdown renderer, graph, canvas — all clide-owned, not pulled from opinionated packages. - **Own the rendering stack.** PTY (via Dart FFI), markdown renderer, graph, canvas — all clide-owned, not pulled from opinionated packages.
- **User/Claude parity.** Every CLI subcommand has a UI affordance, and every UI action has a CLI. See [`D-6`](decisions/architecture.md#d-6-cli-and-event-surface-contract). - **User/Claude parity.** Every CLI subcommand has a UI affordance, and every UI action has a CLI. See [`D-6`](decisions/architecture.md#d-6-cli-and-event-surface-contract).
- **pql: wrap, don't duplicate.** Pql logic only lives in `lib/src/pql/` (pure shell-outs). Clide owns pql's `ignore_files:` config key; it never touches pql's `.pql/` index/cache data. See [`D-3`](decisions/architecture.md#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates). - **pql: wrap, don't duplicate.** Pql logic only lives in `lib/src/pql/` (pure shell-outs). Clide owns pql's `ignore_files:` config key; it never touches pql's `.pql/` index/cache data. See [`D-3`](decisions/architecture.md#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates).
- **Repo-is-the-workspace.** The git repo root is the workspace — no parallel "vault" concept. - **Repo-is-the-workspace.** The git repo root is the workspace — no parallel "vault" concept.
@@ -46,8 +45,7 @@ lib/
test/ # All tests (core subsystems + widgets + goldens + a11y) test/ # All tests (core subsystems + widgets + goldens + a11y)
assets/ # Fonts, themes, grammars, licenses, logo assets/ # Fonts, themes, grammars, licenses, logo
linux/, macos/, web/ # Flutter platform directories linux/, macos/, web/ # Flutter platform directories
ptyc/ # C PTY helper native/ # Vendored native libs (libtree-sitter.so, dugite)
native/ # Vendored native libs (libtree-sitter.so)
decisions/ # D/Q/R records decisions/ # D/Q/R records
docs/ # Design docs, wireframes docs/ # Design docs, wireframes
legacy/ # Python Textual clide v1.2 (frozen) legacy/ # Python Textual clide v1.2 (frozen)
@@ -57,7 +55,7 @@ legacy/ # Python Textual clide v1.2 (frozen)
- **Prefer-zero-deps.** Flutter-SDK widgets first; third-party packages need justification. What stays is exact-pinned in `pubspec.yaml` (no caret ranges). Advisories reviewed before every bump; `pubspec.lock` committed. - **Prefer-zero-deps.** Flutter-SDK widgets first; third-party packages need justification. What stays is exact-pinned in `pubspec.yaml` (no caret ranges). Advisories reviewed before every bump; `pubspec.lock` committed.
- **Document every bundled dependency.** Listed in [`assets/licenses.yaml`](assets/licenses.yaml) with name, kind, version, homepage, license, and purpose. Adding a dep is a two-step commit: add the artefact **and** the `licenses.yaml` entry. See [`D-42`](decisions/tooling.md#d-42-bundled-dependencies-documented-in-licensesyaml). - **Document every bundled dependency.** Listed in [`assets/licenses.yaml`](assets/licenses.yaml) with name, kind, version, homepage, license, and purpose. Adding a dep is a two-step commit: add the artefact **and** the `licenses.yaml` entry. See [`D-42`](decisions/tooling.md#d-42-bundled-dependencies-documented-in-licensesyaml).
- **`ptyc` and any future native supporter tool:** no dep graph by design (libc-only for `ptyc`). "Audit" is reading the source before each bump. - **Native deps (dugite, libtree-sitter):** vendored in `native/`, pinned by SHA. Bumps follow the same advisory-review + `licenses.yaml` rule.
## Commands ## Commands
@@ -71,7 +69,6 @@ make test-a11y # accessibility contract tests
make test-integration# real app boot integration tests make test-integration# real app boot integration tests
make build-linux # flutter build linux make build-linux # flutter build linux
make build-macos # flutter build macos make build-macos # flutter build macos
make ptyc-build # build the ptyc PTY-spawn helper
make push-check # pre-push gate: decisions + core + fast tests + a11y make push-check # pre-push gate: decisions + core + fast tests + a11y
make hooks # install the repo's git hooks (one-time setup) make hooks # install the repo's git hooks (one-time setup)
make clean # remove build artefacts make clean # remove build artefacts
+2 -32
View File
@@ -234,40 +234,11 @@ dugite-fetch: ## Download and extract the dugite-native git distribution.
dugite-clean: ## Remove the dugite-native directory. dugite-clean: ## Remove the dugite-native directory.
rm -rf $(DUGITE_DIR) rm -rf $(DUGITE_DIR)
# -- ptyc (C supporter tool) ---------------------------------------------
PTYC_PRESENT := $(shell test -f ptyc/Makefile && echo yes || echo no)
.PHONY: ptyc-build
ptyc-build: ## Build the ptyc PTY-spawn helper.
ifeq ($(PTYC_PRESENT),yes)
$(MAKE) -C ptyc
else
@echo "(ptyc/ not scaffolded yet; skipping)"
endif
.PHONY: ptyc-test
ptyc-test: ## Run ptyc smoke tests (SCM_RIGHTS round-trip).
ifeq ($(PTYC_PRESENT),yes)
$(MAKE) -C ptyc test
else
@echo "(ptyc/ not scaffolded yet; skipping)"
endif
.PHONY: ptyc-clean
ptyc-clean: ## Clean ptyc build artefacts.
ifeq ($(PTYC_PRESENT),yes)
$(MAKE) -C ptyc clean
else
@echo "(ptyc/ not scaffolded yet; skipping)"
endif
# -- security ------------------------------------------------------------- # -- security -------------------------------------------------------------
.PHONY: security .PHONY: security
security: ## Dart advisory review + ptyc source review. security: ## Dart advisory review.
@echo "security: Dart advisories reviewed manually before pubspec.yaml bumps;" @echo "security: Dart advisories reviewed manually before pubspec.yaml bumps."
@echo " ptyc is reviewed by reading it (tiny libc-only C)."
# -- pre-push gate -------------------------------------------------------- # -- pre-push gate --------------------------------------------------------
@@ -288,6 +259,5 @@ hooks: ## Install the repo's git hooks.
.PHONY: clean .PHONY: clean
clean: ## Remove build artefacts. clean: ## Remove build artefacts.
rm -rf build .dart_tool rm -rf build .dart_tool
$(MAKE) ptyc-clean
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
-434
View File
@@ -1,434 +0,0 @@
// clide — CLI + daemon entry point.
//
// One binary, two modes (per D-005):
// * `clide <subcommand>` — one-shot; connects to the daemon socket,
// sends a request, prints the response, exits with the D-006
// exit code.
// * `clide --daemon` — long-running; owns the socket, dispatches
// requests. Subsystems (pane, files, editor, …) register handlers
// at boot.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:clide/clide.dart';
import 'package:clide/kernel/src/toolchain.dart';
// Daemon-only deep imports — these pull in dart:ffi (PTY) and
// daemon-subsystem wiring that the Flutter app doesn't need and
// can't compile for web. See lib/clide.dart for the barrel split.
import 'package:clide/src/daemon/editor_commands.dart';
import 'package:clide/src/daemon/files_commands.dart';
import 'package:clide/src/daemon/git_commands.dart';
import 'package:clide/src/daemon/pane_commands.dart';
import 'package:clide/src/daemon/pql_commands.dart';
import 'package:clide/src/editor/registry.dart' show EditorRegistry;
import 'package:clide/src/panes/registry.dart';
Future<void> main(List<String> argv) async {
if (argv.isEmpty) {
_printHelp(stdout);
exit(0);
}
if (argv.first == '--daemon') {
await _runDaemon(argv.sublist(1));
return;
}
// Tier-2 single-word shortcuts (per CLAUDE.md). Each maps a flat
// positional argv into the structured IPC shape of the canonical
// editor.* / pane.* verb. Keeps Claude's tool-use pattern short.
final rest = argv.sublist(1);
switch (argv.first) {
case '--version':
case 'version':
await _runCliArgs('version', const {}, exitOnOk: true);
case '--help':
case '-h':
case 'help':
_printHelp(stdout);
exit(0);
case 'ping':
await _runCliArgs('ping', const {}, exitOnOk: true);
case 'open':
if (rest.isEmpty) _die('usage: clide open <path>');
await _runCliArgs('editor.open', {'path': rest.first}, exitOnOk: true);
case 'active':
await _runCliArgs('editor.active', const {}, exitOnOk: true);
case 'insert':
final text = await _readTextArg(rest);
await _runCliArgs('editor.insert', {'text': text}, exitOnOk: true);
case 'replace-selection':
final text = await _readTextArg(rest);
await _runCliArgs(
'editor.replace-selection',
{'text': text},
exitOnOk: true,
);
case 'save':
await _runCliArgs('editor.save', const {}, exitOnOk: true);
case 'git':
await _runGit(rest);
case 'tail':
await _runTail(rest);
default:
// Unknown-to-the-CLI commands still go over IPC — the daemon is
// authoritative about what's registered. Lets extensions add
// subcommands without the CLI caring. Args forward as-is under
// {argv: [...]} so daemon-side can parse whatever shape it wants.
await _runCliArgs(
argv.first,
{'argv': rest},
exitOnOk: true,
);
}
}
void _printHelp(IOSink sink) {
sink.writeln('''
clide $clideVersion — IDE for Claude Code CLI.
Usage:
clide --daemon Run the long-running daemon process.
clide <subcommand> Run a one-shot subcommand against the daemon.
Built-in subcommands:
ping Round-trip a ping to the daemon.
version Print the clide version.
help Print this help.
Editor (tier 2):
open <path> Open a file in the editor (editor.open).
active Print the active buffer (editor.active).
insert <text|-> Insert text at the cursor in the active buffer.
`-` reads text from stdin.
replace-selection <…> Replace the selected text in the active buffer.
`-` reads text from stdin.
save Save the active buffer (editor.save).
Git (tier 3):
git status Working tree status (staged/unstaged/conflicts).
git diff [--staged] [P] Diff for file(s) P, or all if omitted.
git stage <paths…> Stage files (git add).
git stage-all Stage everything (git add -A).
git unstage [paths…] Unstage files (git reset HEAD).
git discard <paths…> Discard unstaged changes in files.
git commit "<msg>" Commit staged changes.
git log [--count N] Recent commit log (default 20).
git stash [--message M] Stash working changes.
git stash-pop Pop the top stash entry.
git pull Pull from remote.
git push [remote] [br] Push to remote.
Event subscription:
tail --events [--filter SUBSYSTEM[:ID]]
Stream events as JSON lines. --filter keeps
only events from one subsystem, optionally
narrowed to a single id. Exits on SIGINT.
Any other subcommand is forwarded to the daemon; registered handlers
(e.g. `clide git status` once `builtin.git` lands) resolve there.
Matches D-006's exit-code contract:
0 success · 1 user-error · 2 tool-error · 3 not-found · 4 conflict
''');
}
Future<void> _runDaemon(List<String> args) async {
final socketPath = defaultSocketPath();
final dispatcher = DaemonDispatcher();
late final DaemonServer server;
server = DaemonServer(
socketPath: socketPath,
dispatch: dispatcher.dispatch,
);
final toolchain = Toolchain();
toolchain.applyResolved(Toolchain.resolvePaths(workspaceRoot: Directory.current.path));
final events = _ServerEventSink(server);
final registry = PaneRegistry(events: events);
registerPaneCommands(dispatcher, registry);
final files = FilesService.atCwd(events: events);
registerFilesCommands(dispatcher, files);
final editor = EditorRegistry(events: events, workspaceRoot: files.root);
registerEditorCommands(dispatcher, editor);
final gitClient = GitClient(toolchain: toolchain, workDir: files.root);
registerGitCommands(dispatcher, gitClient, events);
final pql = PqlClient(workDir: files.root, toolchain: toolchain);
registerPqlCommands(dispatcher, pql);
final stopping = Completer<void>();
void shutdown(ProcessSignal sig) {
if (!stopping.isCompleted) {
stderr.writeln('clide daemon: received ${sig.toString()}, shutting down');
stopping.complete();
}
}
ProcessSignal.sigint.watch().listen(shutdown);
ProcessSignal.sigterm.watch().listen(shutdown);
await server.start();
await stopping.future;
await registry.shutdown();
await editor.shutdown();
await files.shutdown();
await server.stop();
exit(0);
}
/// Thin adapter: the server doesn't `implement DaemonEventSink` itself
/// (that would tie ipc/ to panes/); instead the daemon entrypoint wraps
/// it at the seam where both are known.
class _ServerEventSink implements DaemonEventSink {
_ServerEventSink(this._server);
final DaemonServer _server;
@override
void emit(IpcEvent event) => _server.broadcast(event);
}
// ---------------------------------------------------------------------------
// CLI helpers
// ---------------------------------------------------------------------------
/// Read the "text" argument for insert / replace-selection. A lone
/// `-` means "slurp stdin"; anything else is concatenated into the
/// text body (so `clide insert hello world` emits "hello world").
Future<String> _readTextArg(List<String> rest) async {
if (rest.isEmpty) _die('usage: clide <verb> <text> (or `-` to read stdin)');
if (rest.length == 1 && rest.first == '-') {
final bytes = <int>[];
await for (final chunk in stdin) {
bytes.addAll(chunk);
}
return utf8.decode(bytes);
}
return rest.join(' ');
}
Future<Socket> _connectSocket() async {
final socketPath = defaultSocketPath();
try {
return await Socket.connect(
InternetAddress(socketPath, type: InternetAddressType.unix),
0,
);
} catch (_) {
_emitError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'daemon not reachable at $socketPath',
hint: 'run `clide --daemon` in another terminal.',
);
exit(IpcExitCode.toolError);
}
}
Future<void> _runCliArgs(
String cmd,
Map<String, Object?> args, {
required bool exitOnOk,
}) async {
final socket = await _connectSocket();
final request = IpcRequest(id: '1', cmd: cmd, args: args);
socket.writeln(request.encode());
// Responses come back on the same socket. Events may be interleaved
// (the daemon broadcasts), so we skip events until we see the
// response whose id matches our request.
final lines = socket.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter());
try {
await for (final line in lines) {
if (line.isEmpty) continue;
final msg = IpcMessage.decode(line);
if (msg is! IpcResponse) continue;
if (msg.id != request.id) continue;
await socket.close();
if (msg.ok) {
stdout.writeln(jsonEncode(msg.data));
if (exitOnOk) exit(IpcExitCode.ok);
return;
} else {
final err = msg.error!;
_emitError(
code: err.code,
kind: err.kind,
message: err.message,
hint: err.hint,
);
exit(err.code);
}
}
_emitError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'daemon closed socket before responding',
);
exit(IpcExitCode.toolError);
} on FormatException catch (e) {
_emitError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'bad response from daemon: $e',
);
exit(IpcExitCode.toolError);
}
}
/// `clide git <verb> [args]` — maps to `git.*` IPC verbs.
Future<void> _runGit(List<String> args) async {
if (args.isEmpty) {
_die('usage: clide git <verb> [args…]');
}
switch (args.first) {
case 'status':
await _runCliArgs('git.status', const {}, exitOnOk: true);
case 'diff':
final staged = args.contains('--staged');
final paths = args.sublist(1).where((a) => !a.startsWith('--')).toList();
await _runCliArgs(
'git.diff',
{'staged': staged, if (paths.isNotEmpty) 'paths': paths},
exitOnOk: true,
);
case 'stage':
final paths = args.sublist(1);
if (paths.isEmpty) _die('usage: clide git stage <path…>');
await _runCliArgs('git.stage', {'paths': paths}, exitOnOk: true);
case 'stage-all':
await _runCliArgs('git.stage-all', const {}, exitOnOk: true);
case 'unstage':
final paths = args.sublist(1);
await _runCliArgs('git.unstage', {'paths': paths}, exitOnOk: true);
case 'discard':
final paths = args.sublist(1);
if (paths.isEmpty) _die('usage: clide git discard <path…>');
await _runCliArgs('git.discard', {'paths': paths}, exitOnOk: true);
case 'commit':
if (args.length < 2) _die('usage: clide git commit "<message>"');
final message = args.sublist(1).join(' ');
await _runCliArgs('git.commit', {'message': message}, exitOnOk: true);
case 'log':
var count = 20;
for (var i = 1; i < args.length; i++) {
if (args[i] == '--count' && i + 1 < args.length) {
count = int.tryParse(args[i + 1]) ?? 20;
}
}
await _runCliArgs('git.log', {'count': count}, exitOnOk: true);
case 'stash':
String? message;
for (var i = 1; i < args.length; i++) {
if (args[i] == '--message' && i + 1 < args.length) {
message = args[i + 1];
}
}
await _runCliArgs(
'git.stash',
{if (message != null) 'message': message},
exitOnOk: true,
);
case 'stash-pop':
await _runCliArgs('git.stash-pop', const {}, exitOnOk: true);
case 'pull':
await _runCliArgs('git.pull', const {}, exitOnOk: true);
case 'push':
final rest = args.sublist(1);
final setUpstream = rest.contains('-u');
final positional = rest.where((a) => a != '-u').toList();
await _runCliArgs(
'git.push',
{
if (setUpstream) 'setUpstream': true,
if (positional.isNotEmpty) 'remote': positional[0],
if (positional.length > 1) 'branch': positional[1],
},
exitOnOk: true);
default:
_die('unknown git verb: ${args.first}');
}
}
/// `clide tail --events [--filter SUBSYSTEM[:ID]]` — stream events.
Future<void> _runTail(List<String> args) async {
// Parse flags: --events (required today; keeps us honest when more
// modes like --history land), --filter SUBSYSTEM[:ID].
var wantEvents = false;
String? filterSubsystem;
String? filterId;
for (var i = 0; i < args.length; i++) {
final a = args[i];
if (a == '--events') {
wantEvents = true;
} else if (a == '--filter') {
if (i + 1 >= args.length) _die('--filter requires an argument');
final spec = args[++i];
final colon = spec.indexOf(':');
if (colon < 0) {
filterSubsystem = spec;
} else {
filterSubsystem = spec.substring(0, colon);
filterId = spec.substring(colon + 1);
}
} else {
_die('unknown argument: $a');
}
}
if (!wantEvents) _die('clide tail: pass --events');
final socket = await _connectSocket();
// Shutdown on SIGINT / SIGTERM — close the socket so the stream
// drains and we exit cleanly.
void quit() {
unawaited(socket.close());
}
ProcessSignal.sigint.watch().listen((_) => quit());
ProcessSignal.sigterm.watch().listen((_) => quit());
final lines = socket.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter());
try {
await for (final line in lines) {
if (line.isEmpty) continue;
IpcMessage msg;
try {
msg = IpcMessage.decode(line);
} on FormatException {
continue;
}
if (msg is! IpcEvent) continue;
if (filterSubsystem != null && msg.subsystem != filterSubsystem) continue;
if (filterId != null && msg.data['id'] != filterId) continue;
stdout.writeln(line);
}
} finally {
await socket.close();
}
exit(0);
}
void _emitError({
required int code,
required String kind,
required String message,
String? hint,
}) {
final err = IpcError(code: code, kind: kind, message: message, hint: hint);
stderr.writeln(jsonEncode(err.toJson()));
}
Never _die(String msg) {
_emitError(
code: IpcExitCode.userError,
kind: IpcErrorKind.userError,
message: msg,
);
exit(IpcExitCode.userError);
}
+1 -7
View File
@@ -20,11 +20,6 @@ if ! command -v dart >/dev/null; then
exit 2 exit 2
fi fi
if ! command -v ptyc >/dev/null && [[ ! -x "ptyc/bin/ptyc" ]]; then
echo "test-core: building ptyc (required by PTY tests)"
make -C ptyc >/dev/null
fi
# Hard timeout (seconds). The PTY tests should finish in <5s; IPC/daemon # Hard timeout (seconds). The PTY tests should finish in <5s; IPC/daemon
# tests are faster still. 120s is generous for CI warmup, tiny for a # tests are faster still. 120s is generous for CI warmup, tiny for a
# hang. # hang.
@@ -33,7 +28,7 @@ TIMEOUT_SECONDS=${TIMEOUT_SECONDS:-120}
# Run dart test in its own process group so we can kill descendants on # Run dart test in its own process group so we can kill descendants on
# timeout. `setsid` starts a new session; `timeout --kill-after` SIGKILLs # timeout. `setsid` starts a new session; `timeout --kill-after` SIGKILLs
# after SIGTERM if the test ignores it. # after SIGTERM if the test ignores it.
CORE_DIRS="test/ipc test/pty test/daemon test/git test/panes test/files test/editor test/cli test/pql" CORE_DIRS="test/ipc test/pty test/daemon test/git test/panes test/files test/editor test/pql"
echo "test-core: dart test ${CORE_DIRS} (timeout ${TIMEOUT_SECONDS}s)" echo "test-core: dart test ${CORE_DIRS} (timeout ${TIMEOUT_SECONDS}s)"
if ! timeout --kill-after=5s "${TIMEOUT_SECONDS}s" \ if ! timeout --kill-after=5s "${TIMEOUT_SECONDS}s" \
@@ -42,7 +37,6 @@ if ! timeout --kill-after=5s "${TIMEOUT_SECONDS}s" \
if [[ $rc -eq 124 ]]; then if [[ $rc -eq 124 ]]; then
echo "test-core: TIMEOUT — killing descendants" >&2 echo "test-core: TIMEOUT — killing descendants" >&2
pkill -9 -f "dart test test/" 2>/dev/null || true pkill -9 -f "dart test test/" 2>/dev/null || true
pkill -9 -f "ptyc" 2>/dev/null || true
exit 1 exit 1
fi fi
exit $rc exit $rc
+1 -9
View File
@@ -1,16 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# End-to-end layer: daemon subprocess + web WASM Playwright smoke. # End-to-end layer: web WASM Playwright smoke.
# Neither fits in `make test`; together they're the "everything still
# works across process/runtime boundaries" gate.
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
echo "==> build bin/clide (required by daemon subprocess test)"
make build
echo "==> daemon subprocess test"
dart test test/daemon/
echo "==> browser WASM smoke (Playwright)" echo "==> browser WASM smoke (Playwright)"
./tools/ui/build.sh ./tools/ui/build.sh
./tools/ui/serve.sh ./tools/ui/serve.sh
+2 -1
View File
@@ -97,7 +97,8 @@ Core, rendering, IPC, kernel, panel manager.
### D-5: Dart core; sidecar dissolved; `ptyc` as pql-peer ### D-5: Dart core; sidecar dissolved; `ptyc` as pql-peer
- **Date:** 2026-04-20 (was ADR 0005; supersedes [R-2](rejected.md#r-2-go-sidecar)) - **Date:** 2026-04-20 (was ADR 0005; supersedes [R-2](rejected.md#r-2-go-sidecar))
- **Amendment (2026-04-23):** The separate daemon process and two-package layout are dissolved per [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server). Dart-core and ptyc-as-peer principles survive; the daemon binary does not. - **Amendment (2026-04-23):** The separate daemon process and two-package layout are dissolved per [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server). Dart-core principle survives; the daemon binary does not.
- **Amendment (2026-05-07):** `ptyc` retired. PTY spawning moved to Dart FFI `forkpty()` (`lib/src/pty/native_pty.dart`). The `ptyc/` source tree, `PtySession`, and `scm_rights.dart` are removed. `pql` remains the sole external supporter tool.
- **Decision:** Three moves. **(1) Dart is the core language.** Everything that used to live under `sidecar/` — IPC server, CLI dispatch, process management, file watching, git shell-outs, pql wrapper — is written in Dart. Two execution modes of one Dart AOT binary: `clide <subcommand>` (one-shot, pql-style) and `clide --daemon` (long-running, owns PTYs and subprocesses, survives app restarts). The Flutter app imports the Dart core as a library *and* connects to the daemon over IPC. **(2) The sidecar directory dissolves.** Layout is `app/` (Flutter UI), `lib/` (Dart core), `bin/clide.dart` (AOT entry), `ptyc/` (C helper), no `sidecar/`, no Go module. **(3) `ptyc` is a pql-peer supporter tool.** Small C binary that does `posix_openpt` + `fork` + `exec` + fd-passing via `SCM_RIGHTS`; clide wraps it the same way it wraps pql. Shells out for every PTY (terminal pane, tmux session, Claude, LSP server, debug adapter — one code path). Consumers other than clide can use `ptyc` standalone. - **Decision:** Three moves. **(1) Dart is the core language.** Everything that used to live under `sidecar/` — IPC server, CLI dispatch, process management, file watching, git shell-outs, pql wrapper — is written in Dart. Two execution modes of one Dart AOT binary: `clide <subcommand>` (one-shot, pql-style) and `clide --daemon` (long-running, owns PTYs and subprocesses, survives app restarts). The Flutter app imports the Dart core as a library *and* connects to the daemon over IPC. **(2) The sidecar directory dissolves.** Layout is `app/` (Flutter UI), `lib/` (Dart core), `bin/clide.dart` (AOT entry), `ptyc/` (C helper), no `sidecar/`, no Go module. **(3) `ptyc` is a pql-peer supporter tool.** Small C binary that does `posix_openpt` + `fork` + `exec` + fd-passing via `SCM_RIGHTS`; clide wraps it the same way it wraps pql. Shells out for every PTY (terminal pane, tmux session, Claude, LSP server, debug adapter — one code path). Consumers other than clide can use `ptyc` standalone.
- **Context:** [R-2](rejected.md#r-2-go-sidecar) picked Go for the sidecar/CLI on two premises: (a) the heavy work belongs in a language separate from the UI layer, and (b) pql is Go so the muscle memory transfers. On reassessment, both premises broke: the "heavy work" is I/O-bound glue that `dart:io` covers cleanly — the real choice was **separate process vs shared language**, and separate-process is what matters. PTY is the one place Dart is genuinely weak (multi-threaded VM can't safely `fork()`), and once you accept a small native helper, *nothing else* needs to be in the same language. - **Context:** [R-2](rejected.md#r-2-go-sidecar) picked Go for the sidecar/CLI on two premises: (a) the heavy work belongs in a language separate from the UI layer, and (b) pql is Go so the muscle memory transfers. On reassessment, both premises broke: the "heavy work" is I/O-bound glue that `dart:io` covers cleanly — the real choice was **separate process vs shared language**, and separate-process is what matters. PTY is the one place Dart is genuinely weak (multi-threaded VM can't safely `fork()`), and once you accept a small native helper, *nothing else* needs to be in the same language.
- **Rationale:** One toolchain for the IDE proper (Flutter + Dart). C toolchain needed only to build `ptyc` — tiny, rarely-changing. Session persistence stays because PTY master fds live in the Dart daemon process, not the app. `ptyc` naming: **p** for *project* (parallel to pql's *project query language*), **ptyc** reads as both "PTY + child" (domain vocabulary) and "PTY + C" (implementation language). Usable from Dart, Python, Go, shell — anywhere a subprocess can be spawned and a fd received. - **Rationale:** One toolchain for the IDE proper (Flutter + Dart). C toolchain needed only to build `ptyc` — tiny, rarely-changing. Session persistence stays because PTY master fds live in the Dart daemon process, not the app. `ptyc` naming: **p** for *project* (parallel to pql's *project query language*), **ptyc** reads as both "PTY + child" (domain vocabulary) and "PTY + C" (implementation language). Usable from Dart, Python, Go, shell — anywhere a subprocess can be spawned and a fd received.
+1 -1
View File
@@ -6,7 +6,7 @@ Test pyramid, drivers, client-side constraint.
### D-23: Test pyramid — seven layers ### D-23: Test pyramid — seven layers
- **Date:** 2026-04-21 - **Date:** 2026-04-21
- **Decision:** The pyramid has seven layers: unit (pure Dart) → widget (pumped + find) → golden (visual primitives) → a11y (semantics coverage + keyboard + contrast + i18n) → integration (`flutter test integration_test/`) → E2E (Playwright driving the WASM build + `clide --daemon` subprocess) → startup-smoke (`ci/smoke_bundle.sh`: build Linux release, run under xvfb for 5 s). - **Decision:** The pyramid has seven layers: unit (pure Dart) → widget (pumped + find) → golden (visual primitives) → a11y (semantics coverage + keyboard + contrast + i18n) → integration (`flutter test integration_test/`) → E2E (Playwright driving the WASM build) → startup-smoke (`ci/smoke_bundle.sh`: build Linux release, run under xvfb for 5 s).
- **Rationale:** Each layer catches a distinct regression class. Skipping any layer means that class ships unprotected. Pushed back when earlier rounds proposed "just widget + E2E"; widget can't catch paint regressions (that's golden), E2E can't catch a11y tree drift (that's semantics). - **Rationale:** Each layer catches a distinct regression class. Skipping any layer means that class ships unprotected. Pushed back when earlier rounds proposed "just widget + E2E"; widget can't catch paint regressions (that's golden), E2E can't catch a11y tree drift (that's semantics).
- **Cost:** Seven CI jobs; total wall time budgeted at < 15 min. Pre-push runs layers 1-4 (< 90 s — see [D-29](#d-29-pre-push-gate-fast-layer-only)). - **Cost:** Seven CI jobs; total wall time budgeted at < 15 min. Pre-push runs layers 1-4 (< 90 s — see [D-29](#d-29-pre-push-gate-fast-layer-only)).
- **Raised by:** 2026-04-21 planning. - **Raised by:** 2026-04-21 planning.
+1 -1
View File
@@ -64,7 +64,7 @@ class _ClaudePaneState extends State<ClaudePane> {
// Primary panes leave the tmux session alive so the next launch // Primary panes leave the tmux session alive so the next launch
// re-attaches via `tmux new-session -A` (D-41). // re-attaches via `tmux new-session -A` (D-41).
// //
// pane.close kills the ptyc-spawned tmux *client*; the tmux server // pane.close kills the PTY-spawned tmux *client*; the tmux server
// keeps the session alive. We need an explicit kill-session for // keeps the session alive. We need an explicit kill-session for
// secondaries to actually disappear (D-41 close semantics). // secondaries to actually disappear (D-41 close semantics).
if (id != null && !widget.isPrimary) { if (id != null && !widget.isPrimary) {
+1 -1
View File
@@ -1,6 +1,6 @@
/// tmux server interactions for Claude panes (D-41 lifecycle). /// tmux server interactions for Claude panes (D-41 lifecycle).
/// ///
/// `pane.close` only kills the ptyc-spawned tmux *client*; tmux is /// `pane.close` only kills the PTY-spawned tmux *client*; tmux is
/// client/server, so the server-side session keeps running after the /// client/server, so the server-side session keeps running after the
/// client disconnects. To honour D-41 ("closing a secondary kills that /// client disconnects. To honour D-41 ("closing a secondary kills that
/// tmux session" + "secondary numbering resets between clide runs"), /// tmux session" + "secondary numbering resets between clide runs"),
+1 -1
View File
@@ -62,7 +62,7 @@ class _TerminalPaneState extends State<TerminalPane> {
if (!mounted) return; if (!mounted) return;
final ipc = _kernelIpc(); final ipc = _kernelIpc();
if (ipc == null || !ipc.isConnected) { if (ipc == null || !ipc.isConnected) {
setState(() => _error = 'Daemon not connected. Start `clide --daemon`.'); setState(() => _error = 'Backend not connected.');
return; return;
} }
+5 -10
View File
@@ -1,8 +1,5 @@
/// clide — Dart core library. /// clide — Dart core library.
/// ///
/// Shared by `bin/clide.dart` (CLI + daemon) and by the Flutter app
/// under `app/` (which depends on this package via `path: ../`).
///
/// See: /// See:
/// * `decisions/architecture.md` `D-005` — layout + language rationale. /// * `decisions/architecture.md` `D-005` — layout + language rationale.
/// * `decisions/architecture.md` `D-006` — CLI + event contract. /// * `decisions/architecture.md` `D-006` — CLI + event contract.
@@ -10,11 +7,10 @@ library;
// Flutter-app-visible surface. Deliberately **does not** export the // Flutter-app-visible surface. Deliberately **does not** export the
// `pty/` or `panes/registry.dart` modules — those import `dart:ffi` // `pty/` or `panes/registry.dart` modules — those import `dart:ffi`
// and pull in the PTY machinery that only runs on desktop. The // and pull in the PTY machinery that only runs on desktop.
// daemon entrypoint (`bin/clide.dart`) imports them via deep paths.
// //
// `Pane` + `PaneKind` + the event-sink interfaces travel here because // `Pane` + `PaneKind` + the event-sink interfaces travel here because
// they're pure data types that both the app and the daemon reference. // they're pure data types referenced throughout the app.
export 'src/daemon/dispatcher.dart'; export 'src/daemon/dispatcher.dart';
export 'src/editor/buffer.dart'; export 'src/editor/buffer.dart';
@@ -28,15 +24,14 @@ export 'src/pql/client.dart' show PqlClient, PqlException;
export 'src/ipc/envelope.dart'; export 'src/ipc/envelope.dart';
export 'src/ipc/paths.dart'; export 'src/ipc/paths.dart';
export 'src/ipc/schema_v1.dart'; export 'src/ipc/schema_v1.dart';
export 'src/ipc/server.dart';
export 'src/panes/event_sink.dart'; export 'src/panes/event_sink.dart';
export 'src/panes/pane.dart' show Pane, PaneKind; export 'src/panes/pane.dart' show Pane, PaneKind;
/// Build-time-stamped version string. /// Build-time-stamped version string.
/// ///
/// The Makefile's `build` target passes `--define=clideVersion=…` when /// The Makefile's `build` target passes `--define=clideVersion=…`,
/// invoking `dart compile exe`, stamping `project.yaml`'s `version:` /// stamping `pubspec.yaml`'s `version:` plus the git short SHA and
/// plus the git short SHA and dirty marker. /// dirty marker.
const clideVersion = String.fromEnvironment( const clideVersion = String.fromEnvironment(
'clideVersion', 'clideVersion',
defaultValue: '2.0.0-dev', defaultValue: '2.0.0-dev',
-2
View File
@@ -60,7 +60,6 @@ class Backend {
git: tcData['git'] as String?, git: tcData['git'] as String?,
pql: tcData['pql'] as String?, pql: tcData['pql'] as String?,
tmux: tcData['tmux'] as String?, tmux: tcData['tmux'] as String?,
ptyc: tcData['ptyc'] as String?,
shell: tcData['shell'] as String?, shell: tcData['shell'] as String?,
gitEnv: (tcData['gitEnv'] as Map?)?.cast<String, String>(), gitEnv: (tcData['gitEnv'] as Map?)?.cast<String, String>(),
)); ));
@@ -86,7 +85,6 @@ class Backend {
git: tcData['git'] as String?, git: tcData['git'] as String?,
pql: tcData['pql'] as String?, pql: tcData['pql'] as String?,
tmux: tcData['tmux'] as String?, tmux: tcData['tmux'] as String?,
ptyc: tcData['ptyc'] as String?,
shell: tcData['shell'] as String?, shell: tcData['shell'] as String?,
gitEnv: (tcData['gitEnv'] as Map?)?.cast<String, String>(), gitEnv: (tcData['gitEnv'] as Map?)?.cast<String, String>(),
)); ));
+3 -4
View File
@@ -45,8 +45,8 @@ void backendEntry(BackendBootMessage boot) {
late Toolchain toolchain; late Toolchain toolchain;
// Phase 1: resolve toolchain — just find binaries, don't init services. // Phase 1: resolve toolchain — just find binaries, don't init services.
// We need a project root for ptyc/dugite paths. Use a sensible // We need a project root for dugite paths. Use a sensible default;
// default; the real project comes from project.open. // the real project comes from project.open.
final resolveRoot = boot.hintRoot ?? Platform.environment['HOME'] ?? '/tmp'; final resolveRoot = boot.hintRoot ?? Platform.environment['HOME'] ?? '/tmp';
toolchain = Toolchain(); toolchain = Toolchain();
toolchain.applyResolved(resolveToolchainPaths(resolveRoot)); toolchain.applyResolved(resolveToolchainPaths(resolveRoot));
@@ -78,7 +78,7 @@ void backendEntry(BackendBootMessage boot) {
final workDir = Directory(projectPath); final workDir = Directory(projectPath);
// Re-resolve toolchain with the actual project root (finds // Re-resolve toolchain with the actual project root (finds
// dugite in native/dugite/, ptyc in ptyc/bin/, etc.) // dugite in native/dugite/, etc.)
toolchain = Toolchain(); toolchain = Toolchain();
toolchain.applyResolved(resolveToolchainPaths(projectPath)); toolchain.applyResolved(resolveToolchainPaths(projectPath));
@@ -138,7 +138,6 @@ Map<String, Object?> _serializeToolchain(Toolchain tc) => {
'git': tc.git, 'git': tc.git,
'pql': tc.pql, 'pql': tc.pql,
'tmux': tc.tmux, 'tmux': tc.tmux,
'ptyc': tc.ptyc,
'shell': tc.shell, 'shell': tc.shell,
'gitEnv': tc.gitEnv, 'gitEnv': tc.gitEnv,
'missing': tc.missing, 'missing': tc.missing,
@@ -1,6 +1,6 @@
{ {
"connected": { "translation": "connected" }, "connected": { "translation": "connected" },
"connected.hint": { "translation": "clide daemon is reachable over the local socket" }, "connected.hint": { "translation": "backend isolate is reachable" },
"disconnected": { "translation": "disconnected" }, "disconnected": { "translation": "disconnected" },
"disconnected.hint": { "translation": "clide daemon is not running — start it with `clide --daemon`" } "disconnected.hint": { "translation": "backend isolate is not running" }
} }
@@ -3,5 +3,5 @@
"subtitle.spawning": { "translation": "spawning shell…" }, "subtitle.spawning": { "translation": "spawning shell…" },
"subtitle.exited": { "translation": "Shell exited." }, "subtitle.exited": { "translation": "Shell exited." },
"error.unavailable": { "translation": "Terminal unavailable" }, "error.unavailable": { "translation": "Terminal unavailable" },
"error.daemon": { "translation": "Daemon not connected. Start `clide --daemon`." } "error.daemon": { "translation": "Backend not connected." }
} }
-1
View File
@@ -58,7 +58,6 @@ class DaemonClient extends ChangeNotifier {
code: IpcExitCode.toolError, code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError, kind: IpcErrorKind.toolError,
message: 'daemon not connected', message: 'daemon not connected',
hint: 'is `clide --daemon` running?',
), ),
)); ));
} }
+1 -9
View File
@@ -5,16 +5,14 @@ import 'package:flutter/foundation.dart';
import '../../src/pty/env.dart'; import '../../src/pty/env.dart';
class ToolCheck extends ChangeNotifier { class ToolCheck extends ChangeNotifier {
bool ptycOk = false;
bool pqlOk = false; bool pqlOk = false;
bool tmuxOk = false; bool tmuxOk = false;
bool gitOk = false; bool gitOk = false;
bool checked = false; bool checked = false;
bool get allOk => ptycOk && pqlOk && tmuxOk && gitOk; bool get allOk => pqlOk && tmuxOk && gitOk;
List<String> get errors => [ List<String> get errors => [
if (!ptycOk) 'ptyc not found',
if (!pqlOk) 'pql not found', if (!pqlOk) 'pql not found',
if (!tmuxOk) 'tmux not found', if (!tmuxOk) 'tmux not found',
if (!gitOk) 'git not found', if (!gitOk) 'git not found',
@@ -24,12 +22,6 @@ class ToolCheck extends ChangeNotifier {
static String? workspaceRoot; static String? workspaceRoot;
Future<void> check() async { Future<void> check() async {
final root = workspaceRoot ?? Directory.current.path;
ptycOk = File('$root/native/linux-x64/ptyc').existsSync() ||
File('$root/native/macos-arm64/ptyc').existsSync() ||
File('$root/native/macos-x64/ptyc').existsSync() ||
File('$root/ptyc/bin/ptyc').existsSync() ||
_existsOnPath('ptyc');
pqlOk = _existsOnPath('pql'); pqlOk = _existsOnPath('pql');
tmuxOk = _existsOnPath('tmux'); tmuxOk = _existsOnPath('tmux');
gitOk = _existsOnPath('git'); gitOk = _existsOnPath('git');
-23
View File
@@ -16,7 +16,6 @@ class ResolvedPaths {
this.git, this.git,
this.pql, this.pql,
this.tmux, this.tmux,
this.ptyc,
this.shell, this.shell,
this.gitEnv, this.gitEnv,
}); });
@@ -24,7 +23,6 @@ class ResolvedPaths {
final String? git; final String? git;
final String? pql; final String? pql;
final String? tmux; final String? tmux;
final String? ptyc;
final String? shell; final String? shell;
final Map<String, String>? gitEnv; final Map<String, String>? gitEnv;
} }
@@ -33,7 +31,6 @@ class Toolchain extends ChangeNotifier {
String? _git; String? _git;
String? _pql; String? _pql;
String? _tmux; String? _tmux;
String? _ptyc;
String? _shell; String? _shell;
Map<String, String>? _gitEnv; Map<String, String>? _gitEnv;
bool _resolved = false; bool _resolved = false;
@@ -41,7 +38,6 @@ class Toolchain extends ChangeNotifier {
String get git => _git ?? 'git'; String get git => _git ?? 'git';
String get pql => _pql ?? 'pql'; String get pql => _pql ?? 'pql';
String get tmux => _tmux ?? 'tmux'; String get tmux => _tmux ?? 'tmux';
String get ptyc => _ptyc ?? 'ptyc';
String get shell => _shell ?? '/bin/bash'; String get shell => _shell ?? '/bin/bash';
/// Extra environment variables for git (e.g. GIT_EXEC_PATH for dugite). /// Extra environment variables for git (e.g. GIT_EXEC_PATH for dugite).
@@ -76,7 +72,6 @@ class Toolchain extends ChangeNotifier {
_git = p.git; _git = p.git;
_pql = p.pql; _pql = p.pql;
_tmux = p.tmux; _tmux = p.tmux;
_ptyc = p.ptyc;
_shell = p.shell; _shell = p.shell;
_gitEnv = p.gitEnv; _gitEnv = p.gitEnv;
_resolved = true; _resolved = true;
@@ -106,20 +101,10 @@ class Toolchain extends ChangeNotifier {
final tmux = _findOnPath('tmux'); final tmux = _findOnPath('tmux');
final shell = _findOnPath(Platform.environment['SHELL']?.split('/').last ?? 'bash'); final shell = _findOnPath(Platform.environment['SHELL']?.split('/').last ?? 'bash');
final ptyc = _firstExisting([
'$workspaceRoot/ptyc/bin/ptyc',
'$workspaceRoot/native/linux-x64/ptyc',
'$workspaceRoot/native/macos-arm64/ptyc',
'$workspaceRoot/native/macos-x64/ptyc',
if (Platform.environment['HOME'] case final home?) '$home/.local/bin/ptyc',
]) ??
_findOnPath('ptyc');
return ResolvedPaths( return ResolvedPaths(
git: git, git: git,
pql: pql, pql: pql,
tmux: tmux, tmux: tmux,
ptyc: ptyc,
shell: shell, shell: shell,
gitEnv: gitEnv, gitEnv: gitEnv,
); );
@@ -182,14 +167,6 @@ ResolvedPaths resolveToolchainPaths(String workspaceRoot) {
git: git, git: git,
pql: _findOnPathStandalone('pql'), pql: _findOnPathStandalone('pql'),
tmux: _findOnPathStandalone('tmux'), 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'), shell: _findOnPathStandalone(Platform.environment['SHELL']?.split('/').last ?? 'bash'),
gitEnv: gitEnv, gitEnv: gitEnv,
); );
+1 -1
View File
@@ -6,7 +6,7 @@ class LuaHost {
/// Boot the vendored liblua. Throws until Tier 6. /// Boot the vendored liblua. Throws until Tier 6.
static Future<LuaHost> start() async { static Future<LuaHost> start() async {
throw UnsupportedError('Lua runtime lands at Tier 6 (supporter tool sibling of ptyc).'); throw UnsupportedError('Lua runtime lands at Tier 6.');
} }
Future<void> dispose() async {} Future<void> dispose() async {}
+1 -1
View File
@@ -6,7 +6,7 @@
/// editor.read editor.set-selection editor.set-content /// editor.read editor.set-selection editor.set-content
/// ///
/// Single-word CLI shortcuts (`clide open`, `clide active`, …) map /// Single-word CLI shortcuts (`clide open`, `clide active`, …) map
/// one-to-one onto these in `bin/clide.dart`. /// one-to-one onto these via the IPC dispatch layer.
library; library;
import 'dart:io' show FileSystemException; import 'dart:io' show FileSystemException;
-144
View File
@@ -1,144 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:clide/src/ipc/envelope.dart';
typedef RequestDispatcher = Future<IpcResponse> Function(IpcRequest request);
/// Default per-request timeout. A handler that doesn't return within
/// this window gets a `tool_error` response so the connection's read
/// pipeline isn't blocked indefinitely. Long-running commands (git
/// pull/push, large pql queries) can override per-command later.
const Duration _kDefaultRequestTimeout = Duration(seconds: 60);
/// Unix-socket JSON-lines server. Each connection is an independent
/// bidirectional line-framed stream: client writes requests, daemon
/// writes responses + events on the same socket.
class DaemonServer {
DaemonServer({
required this.socketPath,
required this.dispatch,
Duration requestTimeout = _kDefaultRequestTimeout,
}) : _requestTimeout = requestTimeout;
final String socketPath;
final RequestDispatcher dispatch;
final Duration _requestTimeout;
ServerSocket? _server;
final Set<Socket> _clients = {};
/// Broadcast [event] to every currently-connected client. Sockets
/// that error on write are dropped — the client's read side will
/// notice the close. Errors are logged so silent event loss is
/// debuggable.
void broadcast(IpcEvent event) {
final line = event.encode();
for (final c in List<Socket>.from(_clients)) {
try {
c.writeln(line);
} catch (e) {
stderr.writeln('clide daemon: broadcast write failed (${event.subsystem}.${event.kind}): $e');
_clients.remove(c);
}
}
}
Future<void> start() async {
final addr = InternetAddress(socketPath, type: InternetAddressType.unix);
try {
_server = await ServerSocket.bind(addr, 0);
} on SocketException {
// Either a stale socket from a prior crash, or a live daemon.
// Probe by trying to connect — if a live peer answers, refuse
// to start so we don't rip its socket out.
try {
final probe = await Socket.connect(addr, 0).timeout(const Duration(milliseconds: 200));
await probe.close();
throw StateError('clide daemon already running at $socketPath');
} on TimeoutException {
// No one answered — proceed to unlink and rebind.
} on SocketException {
// No one listening — proceed to unlink and rebind.
}
try {
await File(socketPath).delete();
} catch (_) {}
_server = await ServerSocket.bind(addr, 0);
}
stderr.writeln('clide daemon listening on $socketPath');
_server!.listen(_handleClient, onError: (e) {
stderr.writeln('clide daemon accept error: $e');
});
}
Future<void> stop() async {
for (final c in List<Socket>.from(_clients)) {
await c.close();
}
_clients.clear();
await _server?.close();
_server = null;
try {
await File(socketPath).delete();
} catch (_) {}
}
void _handleClient(Socket client) {
_clients.add(client);
client.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()).listen(
(line) => _handleLine(client, line),
onDone: () => _clients.remove(client),
onError: (Object e) {
stderr.writeln('clide daemon client error: $e');
_clients.remove(client);
},
cancelOnError: true,
);
}
Future<void> _handleLine(Socket client, String line) async {
if (line.isEmpty) return;
IpcMessage? msg;
try {
msg = IpcMessage.decode(line);
} on FormatException catch (e) {
stderr.writeln('clide daemon: bad line from client: $e');
return;
}
if (msg is! IpcRequest) return;
IpcResponse resp;
try {
resp = await dispatch(msg).timeout(_requestTimeout);
} on TimeoutException {
stderr.writeln('clide daemon: dispatch timeout for ${msg.cmd} (${_requestTimeout.inSeconds}s)');
resp = IpcResponse.err(
id: msg.id,
error: IpcError(
code: 2,
kind: 'tool_error',
message: 'request timed out after ${_requestTimeout.inSeconds}s: ${msg.cmd}',
),
);
} catch (e, st) {
stderr.writeln('clide daemon: dispatch error for ${msg.cmd}: $e\n$st');
resp = IpcResponse.err(
id: msg.id,
error: IpcError(
code: 2,
kind: 'tool_error',
message: 'dispatch failed: $e',
),
);
}
try {
client.writeln(resp.encode());
} catch (e) {
// Client disconnected mid-dispatch — drop it so future events
// don't try to write to a dead socket.
stderr.writeln('clide daemon: response write failed (${msg.cmd}): $e');
_clients.remove(client);
}
}
}
+2 -2
View File
@@ -2,8 +2,8 @@
/// travels cleanly into the Flutter app (which can't depend on /// travels cleanly into the Flutter app (which can't depend on
/// `dart:ffi`-using code for the web build). /// `dart:ffi`-using code for the web build).
/// ///
/// The daemon's [PaneRegistry] keeps a parallel `PtySession` keyed on /// [PaneRegistry] keeps a parallel [NativePty] keyed on [id] and
/// [id] and mutates [isClosed] when the session exits. /// mutates [isClosed] when the session exits.
library; library;
/// Kind of a pane. Keep this enum small and explicit — each kind /// Kind of a pane. Keep this enum small and explicit — each kind
+3 -7
View File
@@ -1,6 +1,6 @@
/// [PaneRegistry] — daemon-side state for all live panes. /// [PaneRegistry] — backend-side state for all live panes.
/// ///
/// Owns the [PtySession] per pane, generates `p_N` ids, and forwards /// Owns the [NativePty] per pane, generates `p_N` ids, and forwards
/// pty output + lifecycle changes as IPC events via a [DaemonEventSink]. /// pty output + lifecycle changes as IPC events via a [DaemonEventSink].
/// Pane commands (pane.spawn / list / write / resize / close) resolve /// Pane commands (pane.spawn / list / write / resize / close) resolve
/// against this registry; extension UIs subscribe to the emitted events. /// against this registry; extension UIs subscribe to the emitted events.
@@ -31,10 +31,6 @@ class PaneRegistry {
Pane? get(String id) => _panes[id]; Pane? get(String id) => _panes[id];
/// Spawn a child under a PTY and wire its output to events. /// Spawn a child under a PTY and wire its output to events.
///
/// [ptycPath] is plumbed through to [PtySession.spawn]; callers that
/// have a dev-built `ptyc/bin/ptyc` or a non-PATH install can point
/// at it explicitly.
Future<Pane> spawn({ Future<Pane> spawn({
required PaneKind kind, required PaneKind kind,
required List<String> argv, required List<String> argv,
@@ -49,7 +45,7 @@ class PaneRegistry {
final arguments = argv.length > 1 ? argv.sublist(1) : const <String>[]; final arguments = argv.length > 1 ? argv.sublist(1) : const <String>[];
// Merge the caller's env on top of the process environment + // Merge the caller's env on top of the process environment +
// terminal defaults, matching the old ptyc contract. // Terminal defaults for the PTY child.
final fullEnv = <String, String>{ final fullEnv = <String, String>{
...Platform.environment, ...Platform.environment,
'TERM': 'xterm-256color', 'TERM': 'xterm-256color',
+4 -4
View File
@@ -37,10 +37,10 @@ String _buildExpandedPath() {
return [...missing, ...existing].join(':'); return [...missing, ...existing].join(':');
} }
/// Base env clide's daemon builds for every PTY child. Callers merge /// Base env clide builds for every PTY child. Callers merge with the
/// with the user's environment before passing to `ptyc` — a child that /// user's environment — a child that needs user env like `HOME` /
/// needs user env like `HOME` / `USER` / `SHELL` still gets them; the /// `USER` / `SHELL` still gets them; the keys here override the ones
/// keys here override the ones the child cares about. /// the child cares about.
const Map<String, String> clidePtyEnvDefaults = { const Map<String, String> clidePtyEnvDefaults = {
'TERM': 'xterm-256color', 'TERM': 'xterm-256color',
'COLORTERM': 'truecolor', 'COLORTERM': 'truecolor',
+3 -3
View File
@@ -5,9 +5,9 @@
/// core library stays Flutter-free per D-005. /// core library stays Flutter-free per D-005.
library; library;
/// A PTY operation failed. [op] identifies the step (`recvmsg`, /// A PTY operation failed. [op] identifies the step (`forkpty`,
/// `socketpair`, `ptyc`, etc.); [errno] is POSIX errno when the /// `read`, `ioctl`, etc.); [errno] is POSIX errno when the failure
/// failure came from a syscall, otherwise `null`. /// came from a syscall, otherwise `null`.
class PtyException implements Exception { class PtyException implements Exception {
const PtyException(this.op, this.message, {this.errno}); const PtyException(this.op, this.message, {this.errno});
+2 -4
View File
@@ -1,9 +1,7 @@
/// Raw FFI bindings to the libc functions the PTY wrapper needs. /// Raw FFI bindings to the libc functions the PTY wrapper needs.
/// ///
/// `dart:io` covers neither `socketpair(2)`, `recvmsg(2)` with ancillary /// `dart:io` doesn't expose `forkpty`, `read`/`write` on raw fds,
/// data, nor read/write on arbitrary file descriptors — the three /// `ioctl`, or `poll` — FFI is the minimum tool for the job.
/// things the [`ptyc`](../../../ptyc/README.md) fd-transfer protocol
/// requires. FFI is the minimum tool for the job.
/// ///
/// Linux + macOS only for now. Windows is covered by platform checks /// Linux + macOS only for now. Windows is covered by platform checks
/// higher up; when Windows support lands it'll need a parallel binding /// higher up; when Windows support lands it'll need a parallel binding
-122
View File
@@ -1,122 +0,0 @@
/// Receive a single file descriptor over a unix socket via
/// `SCM_RIGHTS` ancillary data.
///
/// Pairs with `ptyc`'s `send_fd()`: the peer sends one byte of payload
/// plus the fd in cmsg; this function reads both and returns the fd.
library;
import 'dart:ffi' as ffi;
import 'dart:io' show Platform;
import 'package:ffi/ffi.dart' as pkg_ffi;
import '../errors.dart';
import 'libc.dart' as libc;
/// Blocks on [socketFd] waiting for a single-byte payload carrying a
/// fd over `SCM_RIGHTS`. Returns the received fd on success.
///
/// Throws a [PlatformException] if `recvmsg` fails or the peer sends
/// no ancillary data.
int recvFd(int socketFd) {
// Layout: one-byte payload buffer + CMSG_SPACE(sizeof(int)) control
// buffer. `CMSG_SPACE` is just `ALIGN(sizeof(cmsghdr)) + ALIGN(data)`
// — for a single int that's 16 + 4 rounded up to 8 = 24 on 64-bit,
// but we over-allocate to 32 to be safe across platforms.
const payloadLen = 1;
const controlLen = 32;
final payload = pkg_ffi.calloc<ffi.Uint8>(payloadLen);
final control = pkg_ffi.calloc<ffi.Uint8>(controlLen);
final iov = pkg_ffi.calloc<libc.Iovec>();
try {
iov.ref.iov_base = payload;
iov.ref.iov_len = payloadLen;
int received;
int msgControllen;
if (Platform.isMacOS) {
final msg = pkg_ffi.calloc<libc.MsghdrDarwin>();
try {
msg.ref.msg_name = ffi.nullptr;
msg.ref.msg_namelen = 0;
msg.ref.msg_iov = iov;
msg.ref.msg_iovlen = 1;
msg.ref.msg_control = control.cast();
msg.ref.msg_controllen = controlLen;
msg.ref.msg_flags = 0;
while (true) {
received = libc.recvmsgDarwin(socketFd, msg, 0);
if (received >= 0) break;
final err = libc.errno;
if (err == 4 /* EINTR */) continue;
throw PtyException('recvmsg', 'recvmsg failed', errno: err);
}
msgControllen = msg.ref.msg_controllen;
} finally {
pkg_ffi.calloc.free(msg);
}
} else {
final msg = pkg_ffi.calloc<libc.Msghdr>();
try {
msg.ref.msg_name = ffi.nullptr;
msg.ref.msg_namelen = 0;
msg.ref.msg_iov = iov;
msg.ref.msg_iovlen = 1;
msg.ref.msg_control = control.cast();
msg.ref.msg_controllen = controlLen;
msg.ref.msg_flags = 0;
while (true) {
received = libc.recvmsgLinux(socketFd, msg, 0);
if (received >= 0) break;
final err = libc.errno;
if (err == 4 /* EINTR */) continue;
throw PtyException('recvmsg', 'recvmsg failed', errno: err);
}
msgControllen = msg.ref.msg_controllen;
} finally {
pkg_ffi.calloc.free(msg);
}
}
if (received == 0 || msgControllen < 16) {
throw const PtyException(
'recvmsg',
'peer closed without sending ancillary data',
);
}
// Parse the first cmsghdr out of the control buffer. On macOS,
// cmsg_len is socklen_t (4 bytes); on Linux it's size_t (8 bytes).
int cmsgLevel, cmsgType, dataOffset;
if (Platform.isMacOS) {
final hdr = control.cast<libc.CmsghdrDarwin>().ref;
cmsgLevel = hdr.cmsg_level;
cmsgType = hdr.cmsg_type;
dataOffset = ffi.sizeOf<libc.CmsghdrDarwin>();
} else {
final hdr = control.cast<libc.CmsghdrLinux>().ref;
cmsgLevel = hdr.cmsg_level;
cmsgType = hdr.cmsg_type;
dataOffset = ffi.sizeOf<libc.CmsghdrLinux>();
}
if (cmsgLevel != libc.solSocket || cmsgType != libc.scmRights) {
throw PtyException(
'recvmsg',
'unexpected cmsg level=$cmsgLevel type=$cmsgType',
);
}
final fdPtr = (control + dataOffset).cast<ffi.Int32>();
return fdPtr.value;
} finally {
pkg_ffi.calloc.free(iov);
pkg_ffi.calloc.free(control);
pkg_ffi.calloc.free(payload);
}
}
+2 -5
View File
@@ -1,8 +1,7 @@
/// Native PTY via forkpty() — replaces the ptyc helper binary. /// Native PTY via forkpty().
/// ///
/// Uses Dart FFI to call forkpty() directly. The master fd stays /// Uses Dart FFI to call forkpty() directly. The master fd stays
/// in-process (no socketpair, no SCM_RIGHTS). The reader isolate /// in-process. The reader isolate uses poll() for clean shutdown.
/// uses poll() for clean shutdown.
/// ///
/// Based on the pty-spike proof-of-concept. Platform-aware: /// Based on the pty-spike proof-of-concept. Platform-aware:
/// macOS: forkpty in libSystem (DynamicLibrary.process) /// macOS: forkpty in libSystem (DynamicLibrary.process)
@@ -82,8 +81,6 @@ const _kWnohang = 1;
// -- NativePty -------------------------------------------------------------- // -- NativePty --------------------------------------------------------------
/// A pseudo-terminal backed by forkpty() via Dart FFI. /// A pseudo-terminal backed by forkpty() via Dart FFI.
///
/// Drop-in replacement for the old ptyc-based PtySession.
class NativePty { class NativePty {
final int _fd; final int _fd;
final int pid; final int pid;
-442
View File
@@ -1,442 +0,0 @@
/// [PtySession] — high-level PTY lifecycle.
///
/// Spawns `ptyc` with the given argv/cwd/env, receives the master fd
/// via `SCM_RIGHTS`, and exposes:
///
/// - [output] — a broadcast stream of bytes read from the child.
/// - [write] — send bytes to the child's stdin.
/// - [resize] — change the child's window size.
/// - [kill] — send a signal to the child.
/// - [close] — close the master fd and stop reading.
///
/// Reading happens in a background isolate that loops on blocking
/// `read(fd)` calls and posts bytes to the main isolate via a
/// [ReceivePort]. Closing the fd from the main isolate causes `read()`
/// to return EBADF; the isolate sees that and exits.
library;
import 'dart:async';
import 'dart:convert';
import 'dart:ffi' as ffi;
import 'dart:io';
import 'dart:isolate';
import 'dart:typed_data';
import 'package:ffi/ffi.dart' as pkg_ffi;
import 'env.dart';
import 'errors.dart';
import 'ffi/libc.dart' as libc;
import 'ffi/scm_rights.dart' as scm;
class _RecvFdArgs {
const _RecvFdArgs(this.socketFd, this.sendPort);
final int socketFd;
final SendPort sendPort;
}
/// A running PTY child plus its master-fd plumbing.
class PtySession {
PtySession._({
required this.pid,
required int masterFd,
}) : _masterFd = masterFd {
_startReader();
}
/// The spawned child's PID (not ptyc's — ptyc has already exited).
final int pid;
int _masterFd;
final _outputCtrl = StreamController<Uint8List>.broadcast();
final _readerExited = Completer<void>();
Isolate? _readerIsolate;
ReceivePort? _readerPort;
/// Broadcast stream of raw bytes from the child's stdout/stderr.
Stream<Uint8List> get output => _outputCtrl.stream;
/// Whether the session is still alive.
bool get isClosed => _masterFd < 0;
/// Spawn a child under a PTY.
///
/// [argv] must be non-empty; [argv[0]] is resolved via PATH. [env]
/// is merged onto the parent process env via [mergePtyEnv] so
/// terminal children inherit `HOME` / `USER` while clide's
/// true-colour defaults still take effect.
///
/// [ptycPath] defaults to looking for `ptyc` on PATH; dev setups
/// that haven't `make install`'d the helper can point at the
/// development build under `ptyc/bin/ptyc`.
static Future<PtySession> spawn({
required List<String> argv,
String? cwd,
Map<String, String>? env,
int cols = 80,
int rows = 24,
String ptycPath = 'ptyc',
}) async {
if (argv.isEmpty) {
throw ArgumentError.value(argv, 'argv', 'must be non-empty');
}
// socketpair for the fd transfer.
final sv = pkg_ffi.calloc<ffi.Int32>(2);
int parentSock = -1;
int childSock = -1;
Process? proc;
try {
final rc = libc.socketpair(libc.afUnix, libc.sockStream, 0, sv);
if (rc < 0) {
throw PtyException('socketpair', 'socketpair failed', errno: libc.errno);
}
parentSock = sv[0];
childSock = sv[1];
// Build the JSON request for ptyc.
final req = _buildRequest(
argv: argv,
cwd: cwd,
env: mergePtyEnv(
processEnv: Platform.environment,
overrides: env,
),
cols: cols,
rows: rows,
);
// Launch ptyc. We pass childSock to it via PTYC_SOCK_FD so ptyc
// reads it from env rather than having to place it at fd 3
// specifically — Dart's Process.start doesn't give us fine
// control over child fd layout.
proc = await Process.start(
ptycPath,
const [],
environment: {
...Platform.environment,
'PTYC_SOCK_FD': childSock.toString(),
},
// Inherit the socket fd into the child. Dart exposes this via
// a private API in recent versions; until it lands we rely on
// default behaviour (Process.start doesn't close arbitrary
// fds inherited from the parent's open-fd set).
mode: ProcessStartMode.normal,
);
// Send the request and close stdin so ptyc sees EOF.
proc.stdin.add(req);
await proc.stdin.close();
// Receive the master fd over the parent side of the socketpair.
// recvFd blocks until ptyc sends — run in a child isolate so the
// calling isolate's event loop stays responsive.
final int masterFd;
try {
masterFd = await _recvFdAsync(parentSock);
} catch (_) {
proc.kill();
rethrow;
}
// Once we own masterFd, every error path below must close it
// before rethrowing. Wrap the rest of the spawn in its own
// try/catch so the cleanup is centralized.
try {
libc.setWinsize(masterFd, cols, rows);
final stdoutLine = await proc.stdout.transform(const Utf8Decoder()).transform(const LineSplitter()).first.timeout(const Duration(seconds: 5));
final pid = _extractPid(stdoutLine);
final code = await proc.exitCode;
if (code != 0) {
final stderr = await proc.stderr.transform(const Utf8Decoder()).join();
libc.close(masterFd);
throw PtyException('ptyc', 'ptyc exited with code $code: $stderr');
}
return PtySession._(pid: pid, masterFd: masterFd);
} catch (_) {
libc.close(masterFd);
rethrow;
}
} finally {
// parent keeps its own fd until the session is closed; ptyc-side
// fd is released either way (ptyc has exited by now).
if (childSock >= 0) libc.close(childSock);
if (parentSock >= 0) libc.close(parentSock);
pkg_ffi.calloc.free(sv);
}
}
/// Send bytes to the child's stdin. Loops on short writes; throws
/// [PtyException] (with errno) on failure. Returns total bytes
/// written, which equals [bytes.length] on success.
int write(List<int> bytes) {
if (isClosed) return 0;
final buf = pkg_ffi.calloc<ffi.Uint8>(bytes.length);
try {
for (var i = 0; i < bytes.length; i++) {
buf[i] = bytes[i];
}
var written = 0;
while (written < bytes.length) {
final n = libc.write(
_masterFd,
buf + written,
bytes.length - written,
);
if (n < 0) {
final err = libc.errno;
if (err == 4 /* EINTR */) continue;
throw PtyException('write', 'write to PTY failed', errno: err);
}
if (n == 0) break;
written += n;
}
return written;
} finally {
pkg_ffi.calloc.free(buf);
}
}
/// Resize the child's terminal.
void resize({required int cols, required int rows}) {
if (isClosed) return;
libc.setWinsize(_masterFd, cols, rows);
}
/// Send a signal to the child. Uses `Process.killPid` for now; a
/// future pass can deliver signals via the PTY's foreground process
/// group so Ctrl-C from the UI works naturally.
bool kill([ProcessSignal signal = ProcessSignal.sigterm]) {
return Process.killPid(pid, signal);
}
/// Close the session. Signals the child, waits briefly for the
/// reader isolate to see EOF on the master fd (natural wakeup), and
/// then closes + force-kills whatever's still around.
///
/// Ordering matters: closing the master fd alone does **not** unblock
/// a `read()` already in flight on Linux — the blocked syscall holds
/// a reference to the kernel file. Killing the child causes the PTY
/// to return EOF on master, which is the clean way to wake the
/// reader. See D-005 notes; a belt-and-braces `poll()` + self-pipe
/// wake path is possible but not worth the FFI surface at Tier 1.
Future<void> close() async {
if (isClosed) return;
final fd = _masterFd;
_masterFd = -1;
// 1. Ask the child nicely so the shell can run its exit traps.
try {
Process.killPid(pid, ProcessSignal.sigterm);
} catch (_) {
// Already gone — fine.
}
// 2. Give the reader isolate up to ~500ms to see EOF and signal
// back via its 'eof' message (set by the existing listener,
// which completes _readerExited).
await _readerExited.future.timeout(
const Duration(milliseconds: 500),
onTimeout: () {},
);
// 3. Belt and braces: SIGKILL the child, close the master, and
// force-kill the isolate regardless. Any still-pending read()
// returns on close via EIO; future reads return EBADF.
try {
Process.killPid(pid, ProcessSignal.sigkill);
} catch (_) {}
libc.close(fd);
_readerPort?.close();
_readerIsolate?.kill(priority: Isolate.immediate);
_readerPort = null;
_readerIsolate = null;
if (!_outputCtrl.isClosed) await _outputCtrl.close();
}
/// Run recvFd in a child isolate so the blocking FFI call doesn't
/// stall the calling isolate's event loop.
static Future<int> _recvFdAsync(int socketFd) async {
final port = ReceivePort();
Isolate? iso;
try {
iso = await Isolate.spawn(_recvFdEntry, _RecvFdArgs(socketFd, port.sendPort));
final result = await port.first;
if (result is int) return result;
throw PtyException('recvFd', '$result');
} finally {
iso?.kill(priority: Isolate.immediate);
port.close();
}
}
static void _recvFdEntry(_RecvFdArgs args) {
try {
final fd = scm.recvFd(args.socketFd);
args.sendPort.send(fd);
} catch (e) {
args.sendPort.send('error: $e');
}
}
// ---------------------------------------------------------------- //
void _startReader() {
final port = ReceivePort();
_readerPort = port;
port.listen((dynamic msg) {
if (msg is Uint8List) {
if (!_outputCtrl.isClosed) _outputCtrl.add(msg);
} else if (msg == 'eof') {
if (!_readerExited.isCompleted) _readerExited.complete();
}
});
Isolate.spawn<_ReaderArgs>(
_readerEntrypoint,
_ReaderArgs(fd: _masterFd, sendPort: port.sendPort),
).then(
(iso) => _readerIsolate = iso,
onError: (Object e) {
// Spawn failure leaves the session unable to ever produce
// output. Surface the error and mark the controller closed
// so consumers don't hang waiting on the stream.
if (!_outputCtrl.isClosed) {
_outputCtrl.addError(PtyException('reader-spawn', '$e'));
_outputCtrl.close();
}
if (!_readerExited.isCompleted) _readerExited.complete();
},
);
}
// -- request builder ------------------------------------------------------
static List<int> _buildRequest({
required List<String> argv,
required String? cwd,
required Map<String, String> env,
required int cols,
required int rows,
}) {
// Minimal JSON emitter — our request never contains non-ASCII,
// so we only need to escape ", \, and the standard control chars.
final sb = StringBuffer('{');
sb.write('"argv":[');
for (var i = 0; i < argv.length; i++) {
if (i > 0) sb.write(',');
sb.write(_json(argv[i]));
}
sb.write(']');
if (cwd != null) {
sb.write(',"cwd":${_json(cwd)}');
}
sb.write(',"env":{');
var first = true;
env.forEach((k, v) {
if (!first) sb.write(',');
first = false;
sb.write('${_json(k)}:${_json(v)}');
});
sb.write('}');
sb.write(',"cols":$cols,"rows":$rows');
sb.write('}');
return utf8.encode(sb.toString());
}
static String _json(String s) {
final b = StringBuffer('"');
for (var i = 0; i < s.length; i++) {
final c = s.codeUnitAt(i);
switch (c) {
case 0x22:
b.write(r'\"');
break;
case 0x5c:
b.write(r'\\');
break;
case 0x08:
b.write(r'\b');
break;
case 0x09:
b.write(r'\t');
break;
case 0x0a:
b.write(r'\n');
break;
case 0x0c:
b.write(r'\f');
break;
case 0x0d:
b.write(r'\r');
break;
default:
if (c < 0x20) {
b.write('\\u${c.toRadixString(16).padLeft(4, '0')}');
} else {
b.writeCharCode(c);
}
}
}
b.write('"');
return b.toString();
}
static int _extractPid(String json) {
// Narrow regex is enough — ptyc's success envelope is known-shape.
final m = RegExp(r'"pid"\s*:\s*(\d+)').firstMatch(json);
if (m == null) {
throw PtyException('ptyc', 'no pid in ptyc response: $json');
}
return int.parse(m.group(1)!);
}
}
// ---------------------------------------------------------------------------
// Reader isolate
// ---------------------------------------------------------------------------
class _ReaderArgs {
const _ReaderArgs({required this.fd, required this.sendPort});
final int fd;
final SendPort sendPort;
}
/// Runs in a separate isolate. Loops on blocking `read(fd)` and posts
/// each chunk back to the main isolate as a `Uint8List`. Exits on
/// EOF, close, or error.
void _readerEntrypoint(_ReaderArgs args) {
const chunk = 65536;
final buf = pkg_ffi.calloc<ffi.Uint8>(chunk);
try {
while (true) {
final n = libc.read(args.fd, buf, chunk);
if (n > 0) {
final bytes = Uint8List(n);
for (var i = 0; i < n; i++) {
bytes[i] = buf[i];
}
args.sendPort.send(bytes);
} else if (n == 0) {
// child closed pty → EOF
args.sendPort.send('eof');
return;
} else {
final err = libc.errno;
if (err == 4 /* EINTR */) continue;
// 9=EBADF (fd closed from main), 5=EIO (child exited on
// Linux). Either way, we're done.
args.sendPort.send('eof');
return;
}
}
} finally {
pkg_ffi.calloc.free(buf);
}
}
-102
View File
@@ -34,7 +34,6 @@ import 'src/daemon/pane_commands.dart';
import 'src/ipc/envelope.dart'; import 'src/ipc/envelope.dart';
import 'src/panes/event_sink.dart'; import 'src/panes/event_sink.dart';
import 'src/panes/registry.dart'; import 'src/panes/registry.dart';
import 'src/pty/session.dart';
import 'src/daemon/dispatcher.dart'; import 'src/daemon/dispatcher.dart';
import 'src/pty/env.dart' show expandedPath; import 'src/pty/env.dart' show expandedPath;
@@ -124,7 +123,6 @@ class _ClideTestAppState extends State<ClideTestApp> {
_log('toolchain.git', tc.git); _log('toolchain.git', tc.git);
_log('toolchain.pql', tc.pql); _log('toolchain.pql', tc.pql);
_log('toolchain.tmux', tc.tmux); _log('toolchain.tmux', tc.tmux);
_log('toolchain.ptyc', tc.ptyc);
_log('toolchain.shell', tc.shell); _log('toolchain.shell', tc.shell);
_log('toolchain.missing', tc.missing.isEmpty ? 'none' : tc.missing.join(', ')); _log('toolchain.missing', tc.missing.isEmpty ? 'none' : tc.missing.join(', '));
_say(''); _say('');
@@ -132,14 +130,12 @@ class _ClideTestAppState extends State<ClideTestApp> {
await _testExists('git', tc.git); await _testExists('git', tc.git);
await _testExists('pql', tc.pql); await _testExists('pql', tc.pql);
await _testExists('tmux', tc.tmux); await _testExists('tmux', tc.tmux);
await _testExists('ptyc', tc.ptyc);
await _testExists('shell', tc.shell); await _testExists('shell', tc.shell);
_say(''); _say('');
await _testExec('git --version', tc.git, ['--version'], workDir); await _testExec('git --version', tc.git, ['--version'], workDir);
await _testExec('pql --version', tc.pql, ['--version'], workDir); await _testExec('pql --version', tc.pql, ['--version'], workDir);
await _testExec('tmux -V', tc.tmux, ['-V'], workDir); await _testExec('tmux -V', tc.tmux, ['-V'], workDir);
await _testExec('ptyc (no args)', tc.ptyc, [], workDir);
await _testExec('shell --version', tc.shell, ['--version'], workDir); await _testExec('shell --version', tc.shell, ['--version'], workDir);
_say(''); _say('');
@@ -191,17 +187,6 @@ class _ClideTestAppState extends State<ClideTestApp> {
return 'exit=${r.exitCode} ${(r.stdout as String).trim()}'; return 'exit=${r.exitCode} ${(r.stdout as String).trim()}';
}); });
// ptyc stdin/stdout test — send a valid request, verify JSON response
await _testAsync('ptyc spawn echo', () async {
final proc = await Process.start(tc.ptyc, []);
// Send a request for /bin/echo — simplest possible child
proc.stdin.write('{"argv":["/bin/echo","hello"],"cwd":"/tmp","env":{},"cols":80,"rows":24}');
await proc.stdin.close();
final stdout = await proc.stdout.transform(const SystemEncoding().decoder).join();
final exitCode = await proc.exitCode;
return 'exit=$exitCode stdout=${stdout.trim().split('\n').first}';
});
_say(''); _say('');
} }
@@ -395,93 +380,6 @@ class _ClideTestAppState extends State<ClideTestApp> {
return 'exit=$exit stderr=${stderr.trim()}'; return 'exit=$exit stderr=${stderr.trim()}';
}); });
// Direct PtySession test — bypasses IPC, tests fd transfer + reader.
await _testAsync('PtySession.spawn direct', () async {
final session = await PtySession.spawn(
argv: [tc.shell, '-c', 'echo DIRECT_PTY_TEST'],
cwd: workDir,
ptycPath: tc.ptyc,
);
_say(' session pid=${session.pid} masterFd exists');
final bytes = <int>[];
final done = Completer<void>();
session.output.listen(
(chunk) {
bytes.addAll(chunk);
_say(' got ${chunk.length} bytes');
},
onDone: () {
_say(' stream done');
if (!done.isCompleted) done.complete();
},
onError: (e) => _say(' stream error: $e'),
);
await done.future.timeout(const Duration(seconds: 5), onTimeout: () {
_say(' timeout waiting for output, got ${bytes.length} bytes so far');
});
await session.close();
final output = utf8.decode(bytes, allowMalformed: true);
final ok = output.contains('DIRECT_PTY_TEST');
return ok ? 'output=$output' : 'no marker in ${bytes.length} bytes: ${output.substring(0, output.length.clamp(0, 100))}';
});
if (!Platform.isMacOS) {
// Additional direct PtySession tests (Linux only — no merged thread).
// Test 1: spawn /bin/echo via PtySession, read output
await _testAsync('pty spawn echo', () async {
final session = await PtySession.spawn(
argv: ['/bin/echo', 'CLIDE_PTY_TEST_OK'],
cwd: workDir,
ptycPath: tc.ptyc,
);
final bytes = <int>[];
final done = Completer<void>();
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 = <int>[];
final done = Completer<void>();
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 = <int>[];
final done = Completer<void>();
session.output.listen(bytes.addAll, onDone: () => done.complete());
await done.future.timeout(const Duration(seconds: 5));
await session.close();
final fileCreated = File(marker).existsSync();
if (fileCreated) File(marker).deleteSync();
return fileCreated ? 'file created + cleaned up' : 'file not created';
});
} // end !Platform.isMacOS
_say(''); _say('');
} }
@@ -28,7 +28,6 @@
<string>(allow process-exec* (literal "__SHELL__"))</string> <string>(allow process-exec* (literal "__SHELL__"))</string>
<string>(allow process-exec* (literal "/usr/bin/which"))</string> <string>(allow process-exec* (literal "/usr/bin/which"))</string>
<string>(allow process-exec* (subpath "__HOMEDIR__/.local/bin"))</string> <string>(allow process-exec* (subpath "__HOMEDIR__/.local/bin"))</string>
<string>(allow process-exec* (subpath "__PROJECTS__/clide/ptyc/bin"))</string>
<string>(allow process-exec* (subpath "__PROJECTS__/clide/native/dugite"))</string> <string>(allow process-exec* (subpath "__PROJECTS__/clide/native/dugite"))</string>
<string>(allow process-fork)</string> <string>(allow process-fork)</string>
<string>(allow file-read* file-write* (subpath "__PROJECTS__"))</string> <string>(allow file-read* file-write* (subpath "__PROJECTS__"))</string>
-27
View File
@@ -1,27 +0,0 @@
# ptyc — PTY-spawn helper for clide.
#
# Single source file, libc only. No configure step. Builds everywhere a
# POSIX-y C compiler + unix sockets exist.
CC ?= cc
CFLAGS ?= -std=c11 -Wall -Wextra -Wpedantic -Werror -O2 -D_FORTIFY_SOURCE=2
LDFLAGS ?=
BIN := bin/ptyc
.PHONY: all
all: $(BIN)
$(BIN): ptyc.c | bin
$(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)
bin:
mkdir -p bin
.PHONY: test
test: $(BIN)
./test_ptyc.sh
.PHONY: clean
clean:
rm -rf bin *.o
-127
View File
@@ -1,127 +0,0 @@
# ptyc
Small POSIX helper that spawns a child process under a PTY and hands
the master fd back to its caller. Language-agnostic; usable from any
program that can fork a subprocess and receive a file descriptor over a
unix socket.
Clide uses it for every PTY it owns (terminal panes, Claude sessions,
tmux wrappers, LSP servers, debug adapters). See
[`D-005`](../decisions/architecture.md#d-005-dart-core-sidecar-dissolved-ptyc-as-pql-peer)
for the architectural rationale; ptyc is a peer of
[`pql`](https://github.com/postmeridiem/pql), not a clide subsystem.
## Build
```sh
make # produces bin/ptyc
make test # runs test_ptyc.sh against the built binary
make clean
```
No third-party dependencies. `CC`, `CFLAGS`, and `LDFLAGS` are
overrideable in the usual way.
## Wire contract
ptyc is a one-shot helper. The caller:
1. Creates a `socketpair(AF_UNIX, SOCK_STREAM, 0)`.
2. Launches `ptyc` as a subprocess, passing one end of the socket to
the child as **file descriptor 3** (the default) or whatever fd is
given in the `PTYC_SOCK_FD` environment variable. The other end of
the socket stays with the caller. The env-var override exists
because some language runtimes (Python's `subprocess` with
`stdout=PIPE`, for example) shuffle their own pipe fds through the
low numbers and it's cheaper for the caller to pick a higher fd
than to dup2 it down.
3. Writes the request as **JSON on stdin** and closes stdin (EOF
signals end of request).
4. Receives the master PTY fd over the socket via **`SCM_RIGHTS`**
ancillary data (with a single-byte `'x'` payload so the receiver
knows when to `recvmsg`).
5. Reads the success response from stdout (single line of JSON) and
reaps the exited `ptyc` process.
### Request
JSON object on stdin. All fields optional except `argv`.
```json
{
"argv": ["bash", "-l"],
"cwd": "/home/me/work",
"env": {"TERM": "xterm-256color", "LANG": "en_US.UTF-8"},
"cols": 80,
"rows": 24
}
```
- `argv` — required, non-empty array of strings. `argv[0]` is resolved
via `PATH`.
- `cwd` — optional. If omitted, the child inherits ptyc's cwd.
- `env` — optional object. If present, the child's environment is
**replaced** with exactly the keys given (ptyc does `clearenv()` and
then `putenv` per entry). If absent, the child inherits ptyc's
environment. This is a deliberate choice: the daemon is expected to
build the env it wants, not rely on a merge.
- `cols`, `rows` — optional. Default `80` × `24`. Applied via
`TIOCSWINSZ` before fork.
### Success response (stdout)
```json
{"ok":true,"pid":12345}
```
One line, trailing newline. The master PTY fd is already on the socket
by the time stdout is written. `pid` is the spawned child's PID — the
caller is responsible for `waitpid`'ing it when appropriate.
### Error response (stderr)
```json
{"ok":false,"error":"exec: No such file or directory","errno":2}
```
Written on stderr. No fd is sent. ptyc exits with a non-zero code.
### Exit codes
| Code | Meaning |
|------|---------|
| `0` | Success — fd sent, success response on stdout. |
| `1` | Bad request — JSON parse error, missing `argv`, bad field values. |
| `2` | Syscall failed — fork, exec, PTY open, `sendmsg`, etc. Check `errno` in the response. |
## Limits
Compile-time caps, deliberately small:
- `MAX_ARGV` = 64 entries
- `MAX_ENV` = 256 entries
- `MAX_INPUT` = 64 KiB request size
These are far above what any reasonable pane invocation needs; if you
hit them you're holding ptyc wrong. Edit the `#define`s in `ptyc.c` and
rebuild.
## Security notes
- The JSON parser is scoped to the shape above. It rejects anything
else. Strings support the standard `\"` `\\` `\/` `\b` `\f` `\n`
`\r` `\t` escapes and ASCII-range `\uXXXX`. Non-ASCII Unicode escapes
(and surrogate pairs) are rejected — the daemon is expected to emit
raw UTF-8 bytes.
- Input is trusted (daemon is local, same user). ptyc does not sanitise
argv or env beyond format-level checks — if the daemon asks ptyc to
exec `rm`, ptyc execs `rm`.
- ptyc does not `setuid` or `setgid`. It runs as the invoking user.
## Session persistence
ptyc is stateless and one-shot. Session persistence (survive app
restart) is the **caller's** concern. Clide achieves it by spawning
ptyc with `tmux new-session -A -s <name> -- <cmd>` for Claude panes;
tmux handles the persistence layer and ptyc just spawns tmux. See
`D-041` (Claude panes — one primary per repo, tmux-backed).
-479
View File
@@ -1,479 +0,0 @@
/*
* ptyc — spawn a child under a PTY, hand the master fd back.
*
* Wire contract (documented in README.md):
* stdin : JSON request {"argv":[...],"cwd":"...","env":{...},"cols":N,"rows":N}
* stdout : JSON response {"ok":true,"pid":N} on success
* stderr : JSON diagnostic {"ok":false,"error":"...","errno":N} on failure
* fd 3 : unix-domain socket; master fd is sent over SCM_RIGHTS on success
*
* Exit codes:
* 0 success
* 1 bad request (JSON parse error, missing field, bad value)
* 2 syscall failed (fork/exec/pty/socket)
*
* Dependencies: libc only. POSIX APIs where available (posix_openpt,
* grantpt, unlockpt, ptsname). Single-threaded, one-shot, ~300 LOC.
*/
#define _POSIX_C_SOURCE 200809L
#define _XOPEN_SOURCE 600
#ifdef __APPLE__
#define _DARWIN_C_SOURCE /* CMSG_SPACE / CMSG_LEN on macOS */
#endif
#include <ctype.h>
#include <errno.h>
#include <fcntl.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <termios.h>
#include <unistd.h>
/* POSIX exposes `environ` but requires an explicit declaration. We
* set it to NULL in the child to replace the inherited environment
* when the caller supplied one — `clearenv()` is GNU-only, not POSIX. */
extern char **environ;
/* Discard write() result without tripping warn_unused_result. We only
* call this on the exec-failure pipe in the child immediately before
* _exit(127); the parent either receives the bytes or notices EOF via
* the CLOEXEC pipe. Nothing useful to do with the return value. */
static void report_errno(int fd, int e) {
ssize_t r = write(fd, &e, sizeof(e));
(void)r;
}
#define MAX_ARGV 64
#define MAX_ENV 256
#define MAX_INPUT (64 * 1024)
/* -------------------------------------------------------------------- */
/* error reporting */
/* -------------------------------------------------------------------- */
static void die_bad_request(const char *msg) {
fprintf(stderr, "{\"ok\":false,\"error\":\"%s\",\"errno\":0}\n", msg);
exit(1);
}
static void die_syscall(const char *msg) {
int e = errno;
/* Avoid quoting edge cases: strerror results don't contain quotes on
* any platform we care about; if this ever bites us we'll escape. */
fprintf(stderr, "{\"ok\":false,\"error\":\"%s: %s\",\"errno\":%d}\n", msg,
strerror(e), e);
exit(2);
}
/* -------------------------------------------------------------------- */
/* minimal JSON parser */
/* */
/* Scoped to exactly the shape we accept. String escapes supported for */
/* the subset we emit (\" \\ \n \r \t \b \f \/ and \uXXXX for ASCII). */
/* Surrogate pairs, nested arrays, and non-string numbers-as-keys are */
/* not supported — the daemon never emits them. */
/* -------------------------------------------------------------------- */
typedef struct {
const char *src;
size_t len;
size_t pos;
} Parser;
static void p_skip_ws(Parser *p) {
while (p->pos < p->len) {
char c = p->src[p->pos];
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
p->pos++;
} else {
break;
}
}
}
static int p_peek(Parser *p) {
p_skip_ws(p);
return p->pos < p->len ? (unsigned char)p->src[p->pos] : -1;
}
static int p_expect(Parser *p, char c) {
if (p_peek(p) != (unsigned char)c) return 0;
p->pos++;
return 1;
}
/* Parse a JSON string into a freshly-allocated NUL-terminated buffer. */
static char *p_string(Parser *p) {
if (p_peek(p) != '"') return NULL;
p->pos++;
size_t start = p->pos;
/* First pass: find end and compute output length. */
size_t out_len = 0;
while (p->pos < p->len && p->src[p->pos] != '"') {
if (p->src[p->pos] == '\\') {
if (p->pos + 1 >= p->len) return NULL;
char esc = p->src[p->pos + 1];
if (esc == 'u') {
if (p->pos + 5 >= p->len) return NULL;
/* We only accept ASCII in \uXXXX. */
for (int i = 2; i < 6; i++) {
if (!isxdigit((unsigned char)p->src[p->pos + i])) return NULL;
}
p->pos += 6;
} else {
p->pos += 2;
}
out_len++;
} else {
p->pos++;
out_len++;
}
}
if (p->pos >= p->len || p->src[p->pos] != '"') return NULL;
size_t end = p->pos;
p->pos++; /* consume closing quote */
char *out = malloc(out_len + 1);
if (!out) return NULL;
size_t j = 0;
for (size_t i = start; i < end;) {
if (p->src[i] == '\\') {
char esc = p->src[i + 1];
switch (esc) {
case '"': out[j++] = '"'; i += 2; break;
case '\\': out[j++] = '\\'; i += 2; break;
case '/': out[j++] = '/'; i += 2; break;
case 'b': out[j++] = '\b'; i += 2; break;
case 'f': out[j++] = '\f'; i += 2; break;
case 'n': out[j++] = '\n'; i += 2; break;
case 'r': out[j++] = '\r'; i += 2; break;
case 't': out[j++] = '\t'; i += 2; break;
case 'u': {
unsigned int cp = 0;
for (int k = 0; k < 4; k++) {
char h = p->src[i + 2 + k];
cp <<= 4;
if (h >= '0' && h <= '9') cp |= (unsigned)(h - '0');
else if (h >= 'a' && h <= 'f') cp |= (unsigned)(h - 'a' + 10);
else if (h >= 'A' && h <= 'F') cp |= (unsigned)(h - 'A' + 10);
}
/* ASCII-range only. Anything else is a request-format bug. */
if (cp > 0x7f) { free(out); return NULL; }
out[j++] = (char)cp;
i += 6;
break;
}
default: free(out); return NULL;
}
} else {
out[j++] = p->src[i++];
}
}
out[j] = '\0';
return out;
}
static int p_int(Parser *p, long *out) {
p_skip_ws(p);
size_t start = p->pos;
if (p->pos < p->len && (p->src[p->pos] == '-' || p->src[p->pos] == '+'))
p->pos++;
int digits = 0;
while (p->pos < p->len && isdigit((unsigned char)p->src[p->pos])) {
p->pos++;
digits++;
}
if (!digits) {
p->pos = start;
return 0;
}
char buf[32];
size_t n = p->pos - start;
if (n >= sizeof(buf)) return 0;
memcpy(buf, p->src + start, n);
buf[n] = '\0';
*out = strtol(buf, NULL, 10);
return 1;
}
/* -------------------------------------------------------------------- */
/* request */
/* -------------------------------------------------------------------- */
typedef struct {
char *argv[MAX_ARGV + 1]; /* NULL-terminated */
int argc;
char *cwd; /* optional, NULL means inherit */
char *env[MAX_ENV + 1]; /* each "KEY=VAL" */
int envc;
int cols;
int rows;
} Request;
static void req_init(Request *r) {
memset(r, 0, sizeof(*r));
r->cols = 80;
r->rows = 24;
}
static void req_free(Request *r) {
for (int i = 0; i < r->argc; i++) free(r->argv[i]);
for (int i = 0; i < r->envc; i++) free(r->env[i]);
free(r->cwd);
}
static void parse_argv(Parser *p, Request *r) {
if (!p_expect(p, '[')) die_bad_request("argv must be an array");
if (p_peek(p) == ']') { p->pos++; return; }
for (;;) {
if (r->argc >= MAX_ARGV) die_bad_request("argv too long");
char *s = p_string(p);
if (!s) die_bad_request("argv element must be a string");
r->argv[r->argc++] = s;
if (p_expect(p, ',')) continue;
if (p_expect(p, ']')) break;
die_bad_request("malformed argv array");
}
}
static void parse_env(Parser *p, Request *r) {
if (!p_expect(p, '{')) die_bad_request("env must be an object");
if (p_peek(p) == '}') { p->pos++; return; }
for (;;) {
if (r->envc >= MAX_ENV) die_bad_request("env too large");
char *k = p_string(p);
if (!k) die_bad_request("env key must be a string");
if (!p_expect(p, ':')) { free(k); die_bad_request("env missing ':'"); }
char *v = p_string(p);
if (!v) { free(k); die_bad_request("env value must be a string"); }
size_t kl = strlen(k), vl = strlen(v);
char *kv = malloc(kl + 1 + vl + 1);
if (!kv) { free(k); free(v); die_syscall("malloc"); }
memcpy(kv, k, kl);
kv[kl] = '=';
memcpy(kv + kl + 1, v, vl);
kv[kl + 1 + vl] = '\0';
free(k); free(v);
r->env[r->envc++] = kv;
if (p_expect(p, ',')) continue;
if (p_expect(p, '}')) break;
die_bad_request("malformed env object");
}
}
static void parse_request(const char *src, size_t len, Request *r) {
Parser p = { .src = src, .len = len, .pos = 0 };
if (!p_expect(&p, '{')) die_bad_request("top-level must be an object");
if (p_peek(&p) == '}') { p.pos++; goto done; }
for (;;) {
char *key = p_string(&p);
if (!key) die_bad_request("key must be a string");
if (!p_expect(&p, ':')) { free(key); die_bad_request("missing ':'"); }
if (strcmp(key, "argv") == 0) {
parse_argv(&p, r);
} else if (strcmp(key, "cwd") == 0) {
r->cwd = p_string(&p);
if (!r->cwd) { free(key); die_bad_request("cwd must be a string"); }
} else if (strcmp(key, "env") == 0) {
parse_env(&p, r);
} else if (strcmp(key, "cols") == 0) {
long v; if (!p_int(&p, &v)) { free(key); die_bad_request("cols must be an integer"); }
if (v < 1 || v > 65535) { free(key); die_bad_request("cols out of range"); }
r->cols = (int)v;
} else if (strcmp(key, "rows") == 0) {
long v; if (!p_int(&p, &v)) { free(key); die_bad_request("rows must be an integer"); }
if (v < 1 || v > 65535) { free(key); die_bad_request("rows out of range"); }
r->rows = (int)v;
} else {
free(key);
die_bad_request("unknown key");
}
free(key);
if (p_expect(&p, ',')) continue;
if (p_expect(&p, '}')) break;
die_bad_request("malformed object");
}
done:
if (r->argc == 0) die_bad_request("argv is required and non-empty");
r->argv[r->argc] = NULL;
r->env[r->envc] = NULL;
}
/* -------------------------------------------------------------------- */
/* read stdin into a bounded buffer */
/* -------------------------------------------------------------------- */
static char *slurp_stdin(size_t *out_len) {
char *buf = malloc(MAX_INPUT);
if (!buf) die_syscall("malloc");
size_t n = 0;
while (n < MAX_INPUT) {
ssize_t r = read(0, buf + n, MAX_INPUT - n);
if (r == 0) break;
if (r < 0) {
if (errno == EINTR) continue;
die_syscall("read(stdin)");
}
n += (size_t)r;
}
if (n == MAX_INPUT) die_bad_request("request too large");
*out_len = n;
return buf;
}
/* -------------------------------------------------------------------- */
/* PTY open + spawn */
/* -------------------------------------------------------------------- */
static void send_fd(int sock, int fd) {
/* sendmsg with SCM_RIGHTS; one byte payload so receiver knows to read. */
char byte = 'x';
struct iovec iov = { .iov_base = &byte, .iov_len = 1 };
union {
struct cmsghdr hdr;
char buf[CMSG_SPACE(sizeof(int))];
} cbuf;
memset(&cbuf, 0, sizeof(cbuf));
struct msghdr msg = {0};
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = cbuf.buf;
msg.msg_controllen = sizeof(cbuf.buf);
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
cmsg->cmsg_level = SOL_SOCKET;
cmsg->cmsg_type = SCM_RIGHTS;
cmsg->cmsg_len = CMSG_LEN(sizeof(int));
memcpy(CMSG_DATA(cmsg), &fd, sizeof(int));
for (;;) {
ssize_t r = sendmsg(sock, &msg, 0);
if (r < 0 && errno == EINTR) continue;
if (r < 0) die_syscall("sendmsg(fd)");
break;
}
}
int main(void) {
Request req;
req_init(&req);
size_t in_len = 0;
char *in = slurp_stdin(&in_len);
parse_request(in, in_len, &req);
free(in);
/* 1. open master */
int master = posix_openpt(O_RDWR | O_NOCTTY);
if (master < 0) die_syscall("posix_openpt");
if (grantpt(master) < 0) die_syscall("grantpt");
if (unlockpt(master) < 0) die_syscall("unlockpt");
/* 2. open slave (ptsname is POSIX; we're single-threaded) */
const char *slave_path = ptsname(master);
if (!slave_path) die_syscall("ptsname");
int slave = open(slave_path, O_RDWR | O_NOCTTY);
if (slave < 0) die_syscall("open(slave)");
/* 3. apply window size */
struct winsize ws = {0};
ws.ws_col = (unsigned short)req.cols;
ws.ws_row = (unsigned short)req.rows;
if (ioctl(master, TIOCSWINSZ, &ws) < 0) die_syscall("ioctl(TIOCSWINSZ)");
/* 4. exec-failure-reporting pipe (CLOEXEC so it auto-closes on success) */
int ef[2];
if (pipe(ef) < 0) die_syscall("pipe");
if (fcntl(ef[1], F_SETFD, FD_CLOEXEC) < 0) die_syscall("fcntl(FD_CLOEXEC)");
pid_t pid = fork();
if (pid < 0) die_syscall("fork");
if (pid == 0) {
/* ---- child ---- */
close(master);
close(ef[0]);
if (setsid() < 0) { report_errno(ef[1], errno); _exit(127); }
#ifdef TIOCSCTTY
if (ioctl(slave, TIOCSCTTY, 0) < 0) { report_errno(ef[1], errno); _exit(127); }
#endif
if (dup2(slave, 0) < 0 || dup2(slave, 1) < 0 || dup2(slave, 2) < 0) {
report_errno(ef[1], errno);
_exit(127);
}
if (slave > 2) close(slave);
if (req.cwd && chdir(req.cwd) < 0) { report_errno(ef[1], errno); _exit(127); }
/* Replace the environment if the caller supplied one; otherwise
* inherit. Daemon is expected to build the env it wants — this is
* not a "merge" API. */
if (req.envc > 0) {
environ = NULL;
for (int i = 0; i < req.envc; i++) {
if (putenv(req.env[i]) != 0) { report_errno(ef[1], errno); _exit(127); }
}
}
execvp(req.argv[0], req.argv);
/* execvp returned → failure */
report_errno(ef[1], errno);
_exit(127);
}
/* ---- parent ---- */
close(slave);
close(ef[1]);
int child_errno = 0;
ssize_t rr;
for (;;) {
rr = read(ef[0], &child_errno, sizeof(child_errno));
if (rr < 0 && errno == EINTR) continue;
break;
}
close(ef[0]);
if (rr == (ssize_t)sizeof(child_errno)) {
/* exec failed in child; reap it so we don't leak a zombie. */
int st;
(void)waitpid(pid, &st, 0);
close(master);
errno = child_errno;
die_syscall("execvp");
}
/* rr == 0: pipe closed via CLOEXEC on successful exec. */
/* Hand the master fd back to the parent caller over a unix socket.
* Default fd is 3; callers that can't reliably place the socket at
* fd 3 (e.g. Python's subprocess with stdout=PIPE shifts pipe fds
* around fd 3) can override via PTYC_SOCK_FD. */
int sock_fd = 3;
const char *sock_env = getenv("PTYC_SOCK_FD");
if (sock_env && *sock_env) {
char *endp = NULL;
long v = strtol(sock_env, &endp, 10);
if (!endp || *endp != '\0' || v < 0 || v > 65535)
die_bad_request("PTYC_SOCK_FD must be a non-negative integer");
sock_fd = (int)v;
}
send_fd(sock_fd, master);
close(master);
/* Emit success response on stdout and exit. */
printf("{\"ok\":true,\"pid\":%ld}\n", (long)pid);
fflush(stdout);
req_free(&req);
return 0;
}
-145
View File
@@ -1,145 +0,0 @@
#!/usr/bin/env bash
# Smoke test for ptyc. Uses python3 for the SCM_RIGHTS fd receive dance.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
BIN="$HERE/bin/ptyc"
if [[ ! -x "$BIN" ]]; then
echo "test: bin/ptyc not built — run 'make' first" >&2
exit 2
fi
python3 - "$BIN" <<'PY'
import errno
import json
import os
import socket
import subprocess
import sys
import time
ptyc = sys.argv[1]
def spawn(req):
"""Launch ptyc, pipe the request in, receive fd + response."""
sock_parent, sock_child = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
child_fd = sock_child.fileno()
env = {**os.environ, "PTYC_SOCK_FD": str(child_fd)}
try:
p = subprocess.Popen(
[ptyc],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
pass_fds=(child_fd,),
env=env,
)
finally:
sock_child.close()
p.stdin.write(json.dumps(req).encode())
p.stdin.close()
fd = None
try:
msg, ancdata, _flags, _addr = sock_parent.recvmsg(1, socket.CMSG_SPACE(4))
for cmsg_level, cmsg_type, cmsg_data in ancdata:
if cmsg_level == socket.SOL_SOCKET and cmsg_type == socket.SCM_RIGHTS:
fd = int.from_bytes(cmsg_data[:4], "little")
break
except OSError:
pass
sock_parent.close()
stdout = p.stdout.read().decode()
stderr = p.stderr.read().decode()
code = p.wait()
return code, stdout, stderr, fd
def expect_ok(req, reads_substr=None):
code, out, err, fd = spawn(req)
assert code == 0, f"exit={code}, stderr={err!r}"
j = json.loads(out)
assert j["ok"] is True, out
assert j["pid"] > 0, out
assert fd is not None and fd >= 0, "no fd received"
pid = j["pid"]
try:
if reads_substr is not None:
chunks = []
deadline = time.time() + 5.0
while time.time() < deadline:
try:
data = os.read(fd, 4096)
except OSError as e:
if e.errno in (errno.EIO,): # child exited, PTY EOF on Linux
break
raise
if not data:
break
chunks.append(data.decode(errors="replace"))
if reads_substr in "".join(chunks):
break
got = "".join(chunks)
assert reads_substr in got, f"expected {reads_substr!r} in {got!r}"
finally:
os.close(fd)
# Child should exit on its own after producing its output for an
# `echo`; give it a moment, then reap.
try:
for _ in range(20):
rpid, _ = os.waitpid(pid, os.WNOHANG)
if rpid == pid:
break
time.sleep(0.05)
else:
os.kill(pid, 9)
os.waitpid(pid, 0)
except ChildProcessError:
pass
def expect_err(req, match_fragment):
code, out, err, fd = spawn(req)
assert code != 0, f"expected failure, got ok: {out!r}"
assert fd is None, "error path must not send an fd"
j = json.loads(err)
assert j["ok"] is False, err
assert match_fragment in j["error"], f"{match_fragment!r} not in {j['error']!r}"
# 1. Happy path: echo prints and exits cleanly.
expect_ok({"argv": ["/bin/echo", "hello-ptyc"]}, reads_substr="hello-ptyc")
# 2. env replacement works — child sees exactly the keys we pass.
expect_ok(
{"argv": ["/usr/bin/env"], "env": {"FOO": "bar", "PATH": "/usr/bin:/bin"}},
reads_substr="FOO=bar",
)
# 3. cwd respected.
expect_ok({"argv": ["/bin/sh", "-c", "pwd"], "cwd": "/tmp"}, reads_substr="/tmp")
# 4. window size propagates (stty reports it).
expect_ok(
{"argv": ["/bin/sh", "-c", "stty size"], "cols": 132, "rows": 42},
reads_substr="42 132",
)
# 5. Bad request: missing argv.
expect_err({}, "argv is required")
# 6. Bad request: argv not an array.
expect_err({"argv": "bash"}, "argv must be an array")
# 7. exec failure reported on error channel.
expect_err({"argv": ["/does/not/exist/nope"]}, "execvp")
# 8. Unknown key rejected.
expect_err({"argv": ["true"], "wat": 1}, "unknown key")
print("ptyc: all smoke tests passed")
PY
-158
View File
@@ -1,158 +0,0 @@
/// End-to-end tests for the tier-2 CLI shortcuts.
///
/// Each test launches `bin/clide --daemon` as a subprocess, exercises
/// a shortcut (`open`, `active`, `insert`, `save`, `tail`), and
/// asserts the JSON response shape. Requires a built `bin/clide`
/// binary — `ci/test_core.sh` runs `make build` first when needed,
/// or here we build on demand.
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:test/test.dart';
const _socketEnv = 'CLIDE_SOCKET_PATH';
void main() {
late Directory sandbox;
late Process daemon;
late String socketPath;
late String clideBin;
setUpAll(() async {
final candidate = File('bin/clide');
if (!candidate.existsSync()) {
final built = await Process.run('make', const ['build']);
if (built.exitCode != 0) {
throw StateError('make build failed: ${built.stderr}');
}
}
clideBin = candidate.absolute.path;
});
setUp(() async {
sandbox = await Directory.systemTemp.createTemp('clide-cli-t2-');
await File('${sandbox.path}/doc.md').writeAsString('alpha beta');
// Each test gets its own socket path so concurrent test runs don't
// collide. Passed through the daemon via env.
socketPath = '${sandbox.path}/daemon.sock';
daemon = await Process.start(
clideBin,
const ['--daemon'],
workingDirectory: sandbox.path,
environment: {
...Platform.environment,
_socketEnv: socketPath,
},
);
// Wait for the "listening" line on stderr so we know it's ready.
final ready = Completer<void>();
daemon.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen((line) {
if (!ready.isCompleted && line.contains('listening')) {
ready.complete();
}
});
await ready.future.timeout(const Duration(seconds: 5));
});
tearDown(() async {
daemon.kill(ProcessSignal.sigterm);
await daemon.exitCode.timeout(const Duration(seconds: 3), onTimeout: () {
daemon.kill(ProcessSignal.sigkill);
return -1;
});
if (sandbox.existsSync()) sandbox.deleteSync(recursive: true);
});
Future<Map<String, Object?>> run(List<String> args) async {
final r = await Process.run(
clideBin,
args,
workingDirectory: sandbox.path,
environment: {
...Platform.environment,
_socketEnv: socketPath,
},
);
expect(r.exitCode, 0, reason: 'stderr: ${r.stderr}');
return jsonDecode(r.stdout.toString().trim()) as Map<String, Object?>;
}
test('clide open <path> returns buffer metadata', () async {
final r = await run(['open', 'doc.md']);
expect(r['id'], startsWith('b_'));
expect(r['path'], 'doc.md');
});
test('clide active reflects the most recent open', () async {
await run(['open', 'doc.md']);
final r = await run(['active']);
final active = r['active']! as Map;
expect(active['path'], 'doc.md');
});
test('clide insert + clide active round-trip', () async {
await run(['open', 'doc.md']);
await run(['insert', 'hello ']);
final r = await run(['active']);
final active = r['active']! as Map;
expect(active['dirty'], isTrue);
expect((active['length'] as num).toInt(), greaterThan('alpha beta'.length));
});
test('clide save clears dirty + writes to disk', () async {
await run(['open', 'doc.md']);
await run(['insert', 'X ']);
await run(['save']);
final active = (await run(['active']))['active']! as Map;
expect(active['dirty'], isFalse);
final disk = await File('${sandbox.path}/doc.md').readAsString();
expect(disk.startsWith('X '), isTrue);
});
test('clide tail --events streams editor.* events', () async {
// Start a tail subscriber.
final tail = await Process.start(
clideBin,
const ['tail', '--events', '--filter', 'editor'],
workingDirectory: sandbox.path,
environment: {
...Platform.environment,
_socketEnv: socketPath,
},
);
final received = <Map<String, Object?>>[];
final sub = tail.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen((line) {
if (line.isEmpty) return;
received.add(jsonDecode(line) as Map<String, Object?>);
});
// Give the subscriber a beat to connect.
await Future<void>.delayed(const Duration(milliseconds: 200));
await run(['open', 'doc.md']);
await run(['insert', 'T ']);
// Wait up to 2s for events.
for (var i = 0; i < 20 && received.length < 3; i++) {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
tail.kill(ProcessSignal.sigint);
await tail.exitCode.timeout(const Duration(seconds: 2), onTimeout: () {
tail.kill(ProcessSignal.sigkill);
return -1;
});
await sub.cancel();
final kinds = received.map((e) => e['kind']).toList();
expect(kinds, containsAll(['editor.opened', 'editor.edited']));
// Confirm filter actually filtered — no pane events made it in.
for (final e in received) {
expect(e['subsystem'], 'editor');
}
});
}
-113
View File
@@ -1,113 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:clide/clide.dart';
import 'package:test/test.dart';
void main() {
group('DaemonServer (in-process)', () {
late DaemonServer server;
late DaemonDispatcher dispatcher;
late String socketPath;
setUp(() async {
final tmp = await Directory.systemTemp.createTemp('clide_daemon_');
socketPath = '${tmp.path}/daemon.sock';
dispatcher = DaemonDispatcher();
server = DaemonServer(
socketPath: socketPath,
dispatch: dispatcher.dispatch,
);
await server.start();
});
tearDown(() async {
await server.stop();
});
test('ping round-trips with pong=true', () async {
final resp = await _send(
socketPath,
IpcRequest(id: '1', cmd: 'ping').encode(),
);
final parsed = IpcMessage.decode(resp) as IpcResponse;
expect(parsed.ok, true);
expect(parsed.id, '1');
expect(parsed.data['pong'], true);
expect(parsed.data['ts'], isA<String>());
expect(parsed.data['version'], isA<String>());
});
test('version returns current clideVersion', () async {
final resp = await _send(
socketPath,
IpcRequest(id: 'v', cmd: 'version').encode(),
);
final parsed = IpcMessage.decode(resp) as IpcResponse;
expect(parsed.ok, true);
expect(parsed.data['version'], clideVersion);
});
test('unknown command returns NotFound (exit code 3)', () async {
final resp = await _send(
socketPath,
IpcRequest(id: 'x', cmd: 'this.does.not.exist').encode(),
);
final parsed = IpcMessage.decode(resp) as IpcResponse;
expect(parsed.ok, false);
expect(parsed.error!.code, IpcExitCode.notFound);
expect(parsed.error!.kind, IpcErrorKind.notFound);
expect(parsed.error!.message, contains('this.does.not.exist'));
});
test('multiple concurrent requests on one connection', () async {
final socket = await Socket.connect(
InternetAddress(socketPath, type: InternetAddressType.unix),
0,
);
for (var i = 0; i < 5; i++) {
socket.writeln(IpcRequest(id: '$i', cmd: 'ping').encode());
}
final lines = <String>[];
final done = Completer<void>();
final sub = socket.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()).listen((line) {
lines.add(line);
if (lines.length == 5) done.complete();
});
await done.future.timeout(const Duration(seconds: 2));
await sub.cancel();
await socket.close();
final ids = lines.map((l) => (IpcMessage.decode(l) as IpcResponse).id).toSet();
expect(ids, {'0', '1', '2', '3', '4'});
});
test('custom handler plugs into dispatcher', () async {
dispatcher.register('test.custom', (req) async {
return IpcResponse.ok(id: req.id, data: {'echo': req.args});
});
final resp = await _send(
socketPath,
IpcRequest(
id: 'c',
cmd: 'test.custom',
args: const {'x': 1},
).encode(),
);
final parsed = IpcMessage.decode(resp) as IpcResponse;
expect(parsed.ok, true);
expect(parsed.data['echo'], {'x': 1});
});
});
}
Future<String> _send(String socketPath, String line) async {
final socket = await Socket.connect(
InternetAddress(socketPath, type: InternetAddressType.unix),
0,
);
socket.writeln(line);
final resp = await socket.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()).first.timeout(const Duration(seconds: 2));
await socket.close();
return resp;
}
-9
View File
@@ -9,7 +9,6 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:clide/clide.dart'; import 'package:clide/clide.dart';
import 'package:clide/kernel/src/toolchain.dart';
import 'package:clide/src/daemon/pane_commands.dart'; import 'package:clide/src/daemon/pane_commands.dart';
import 'package:clide/src/panes/registry.dart'; import 'package:clide/src/panes/registry.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
@@ -17,9 +16,6 @@ import 'package:test/test.dart';
void main() { void main() {
if (!Platform.isLinux && !Platform.isMacOS) return; if (!Platform.isLinux && !Platform.isMacOS) return;
final toolchain = Toolchain();
toolchain.applyResolved(Toolchain.resolvePaths(workspaceRoot: Directory.current.path));
group('pane.* dispatch', () { group('pane.* dispatch', () {
late DaemonDispatcher dispatcher; late DaemonDispatcher dispatcher;
late PaneRegistry registry; late PaneRegistry registry;
@@ -48,7 +44,6 @@ void main() {
final r = await call('pane.spawn', { final r = await call('pane.spawn', {
'argv': const ['/bin/sh', '-c', 'sleep 0.1'], 'argv': const ['/bin/sh', '-c', 'sleep 0.1'],
'kind': 'terminal', 'kind': 'terminal',
'ptyc_path': toolchain.ptyc,
}); });
expect(r.ok, isTrue, reason: r.error?.message); expect(r.ok, isTrue, reason: r.error?.message);
expect(r.data['id'], startsWith('p_')); expect(r.data['id'], startsWith('p_'));
@@ -58,12 +53,10 @@ void main() {
test('pane.list shows spawned panes', () async { test('pane.list shows spawned panes', () async {
await call('pane.spawn', { await call('pane.spawn', {
'argv': const ['/bin/cat'], 'argv': const ['/bin/cat'],
'ptyc_path': toolchain.ptyc,
}); });
await call('pane.spawn', { await call('pane.spawn', {
'argv': const ['/bin/cat'], 'argv': const ['/bin/cat'],
'kind': 'claude', 'kind': 'claude',
'ptyc_path': toolchain.ptyc,
}); });
final r = await call('pane.list', const {}); final r = await call('pane.list', const {});
final panes = (r.data['panes'] as List).cast<Map>(); final panes = (r.data['panes'] as List).cast<Map>();
@@ -74,7 +67,6 @@ void main() {
test('pane.write accepts text or bytes_b64', () async { test('pane.write accepts text or bytes_b64', () async {
final spawn = await call('pane.spawn', { final spawn = await call('pane.spawn', {
'argv': const ['/bin/cat'], 'argv': const ['/bin/cat'],
'ptyc_path': toolchain.ptyc,
}); });
final id = spawn.data['id']! as String; final id = spawn.data['id']! as String;
@@ -98,7 +90,6 @@ void main() {
test('pane.resize + pane.close + pane.focus round-trip', () async { test('pane.resize + pane.close + pane.focus round-trip', () async {
final spawn = await call('pane.spawn', { final spawn = await call('pane.spawn', {
'argv': const ['/bin/cat'], 'argv': const ['/bin/cat'],
'ptyc_path': toolchain.ptyc,
}); });
final id = spawn.data['id']! as String; final id = spawn.data['id']! as String;
-81
View File
@@ -1,81 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:clide/clide.dart';
import 'package:test/test.dart';
/// Subprocess-level daemon smoke. Only runs if `bin/clide` has been
/// built (the test skips itself otherwise). This is the release-gate
/// suite — catches signal-handling, socket-unlink, and version-stamp
/// regressions that the in-process test masks.
void main() {
final binary = File('bin/clide');
group('bin/clide --daemon (subprocess)', () {
setUpAll(() {
if (!binary.existsSync()) {
markTestSkipped('bin/clide not built; run `make build` first to enable this suite');
}
});
test('starts, responds to ping, exits cleanly on SIGTERM', () async {
if (!binary.existsSync()) return;
// Use a fresh socket under a unique temp path so parallel test
// runs don't collide. The daemon resolves its socket path from
// XDG_RUNTIME_DIR + USER (see defaultSocketPath()).
final tmp = await Directory.systemTemp.createTemp('clide_sub_');
final env = Map<String, String>.from(Platform.environment)
..['XDG_RUNTIME_DIR'] = tmp.path
..['USER'] = 'daemon';
final socketPath = '${tmp.path}/clide-daemon.sock';
final process = await Process.start(
binary.absolute.path,
['--daemon'],
environment: env,
);
// Wait for "listening on ..." on stderr before connecting.
final ready = Completer<void>();
final stderrLines = <String>[];
final sub = process.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen((line) {
stderrLines.add(line);
if (line.contains('listening')) ready.complete();
});
try {
await ready.future.timeout(const Duration(seconds: 3));
// Connect and ping
final sock = await Socket.connect(
InternetAddress(socketPath, type: InternetAddressType.unix),
0,
).timeout(const Duration(seconds: 3));
sock.writeln(IpcRequest(id: '1', cmd: 'ping').encode());
final line = await sock.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()).first.timeout(const Duration(seconds: 3));
await sock.close();
final resp = IpcMessage.decode(line) as IpcResponse;
expect(resp.ok, true);
expect(resp.data['pong'], true);
// Clean shutdown
process.kill(ProcessSignal.sigterm);
final exitCode = await process.exitCode.timeout(const Duration(seconds: 3));
expect(exitCode, 0);
// Socket file should be unlinked
expect(await File(socketPath).exists(), false);
} finally {
await sub.cancel();
try {
process.kill(ProcessSignal.sigkill);
} catch (_) {}
try {
await tmp.delete(recursive: true);
} catch (_) {}
}
}, timeout: const Timeout(Duration(seconds: 15)));
});
}
+12
View File
@@ -0,0 +1,12 @@
import 'dart:async';
import 'package:alchemist/alchemist.dart';
import '../helpers/golden_harness.dart';
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
return AlchemistConfig.runWithConfig(
config: clideGoldenConfig(),
run: testMain,
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

+9 -12
View File
@@ -2,21 +2,18 @@ import 'package:alchemist/alchemist.dart';
/// Alchemist config shared across all golden tests. /// Alchemist config shared across all golden tests.
/// ///
/// * CI mode uses the Ahem font (shipped with Flutter's test harness) so /// Platform goldens only — keyed by OS (`goldens/linux/`, `goldens/macos/`).
/// goldens render identically on every Linux runner and developer /// CI goldens (Ahem font in `goldens/ci/`) are disabled because Skia's
/// machine. Any drift between platforms points to a real theme-token /// geometric anti-aliasing differs between macOS and Linux even with Ahem,
/// regression, not a font-rendering fluke. /// producing sub-pixel diffs that fail cross-platform.
/// * Local mode keeps developer-machine fonts so you can eyeball AlchemistConfig clideGoldenConfig() {
/// renders naturally; the `--update-goldens` workflow still produces return const AlchemistConfig(
/// CI-valid goldens because CI runs the config below.
AlchemistConfig clideGoldenConfig({bool forceCiMode = false}) {
return AlchemistConfig(
theme: null, // we're not using Material ThemeData theme: null, // we're not using Material ThemeData
platformGoldensConfig: PlatformGoldensConfig( platformGoldensConfig: PlatformGoldensConfig(
enabled: !forceCiMode,
),
ciGoldensConfig: const CiGoldensConfig(
enabled: true, enabled: true,
), ),
ciGoldensConfig: CiGoldensConfig(
enabled: false,
),
); );
} }
+2 -2
View File
@@ -1,8 +1,8 @@
/// Unit tests for [PaneRegistry]. /// Unit tests for [PaneRegistry].
/// ///
/// Exercises spawn / list / write / resize / close against the real /// Exercises spawn / list / write / resize / close against the real
/// `ptyc` binary (small enough, and realistic enough, to not be worth /// NativePty (forkpty via FFI). Events are captured via
/// mocking). Events are captured via [RecordingEventSink]. /// [RecordingEventSink].
library; library;
import 'dart:async'; import 'dart:async';