diff --git a/CHANGELOG.md b/CHANGELOG.md index c76bd8d0..316cdc21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,18 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit. ### Added +- IPC `pane` subsystem in the daemon (per D-006). Commands: + `pane.spawn | list | focus | close | write | resize | tail`. Events: + `pane.spawned`, `pane.output` (base64-framed), `pane.exit`, + `pane.resized`, `pane.focused`, `pane.closed`. `PaneRegistry` owns + per-pane `PtySession` lifecycles + id generation (`p_N`); a + `DaemonEventSink` seam lets handlers emit events without depending + on the IPC server package. `DaemonServer.broadcast()` fans events + out to every connected client (a later pass adds per-client + `--filter` scoping). Panes carry a `kind:` field — `terminal` today, + `claude` ready for step 7. Covered by 14 new Dart core tests + exercising the real registry + dispatcher against the `ptyc` helper. + - `PtySession` in the Dart core (`lib/src/pty/`) — spawns a child under a PTY via the `ptyc` supporter tool, receives the master fd over `SCM_RIGHTS`, and exposes a byte stream, write, resize, and diff --git a/bin/clide.dart b/bin/clide.dart index 33da1042..f6eb44fa 100644 --- a/bin/clide.dart +++ b/bin/clide.dart @@ -67,10 +67,13 @@ Matches ADR 0006's exit-code contract: Future _runDaemon(List args) async { final socketPath = defaultSocketPath(); final dispatcher = DaemonDispatcher(); - final server = DaemonServer( + late final DaemonServer server; + server = DaemonServer( socketPath: socketPath, dispatch: dispatcher.dispatch, ); + final registry = PaneRegistry(events: _ServerEventSink(server)); + registerPaneCommands(dispatcher, registry); final stopping = Completer(); void shutdown(ProcessSignal sig) { @@ -85,10 +88,22 @@ Future _runDaemon(List args) async { await server.start(); await stopping.future; + await registry.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); +} + Future _runCli( String cmd, List args, { diff --git a/lib/clide.dart b/lib/clide.dart index 157d804f..dee86f93 100644 --- a/lib/clide.dart +++ b/lib/clide.dart @@ -9,10 +9,14 @@ library; export 'src/daemon/dispatcher.dart'; +export 'src/daemon/pane_commands.dart'; export 'src/ipc/envelope.dart'; export 'src/ipc/paths.dart'; export 'src/ipc/schema_v1.dart'; export 'src/ipc/server.dart'; +export 'src/panes/event_sink.dart'; +export 'src/panes/pane.dart' show Pane, PaneKind; +export 'src/panes/registry.dart' show PaneRegistry; export 'src/pty/errors.dart' show PtyException; export 'src/pty/pty.dart'; diff --git a/lib/src/daemon/pane_commands.dart b/lib/src/daemon/pane_commands.dart new file mode 100644 index 00000000..4e451573 --- /dev/null +++ b/lib/src/daemon/pane_commands.dart @@ -0,0 +1,173 @@ +/// Registers pane.* command handlers on a [DaemonDispatcher]. +/// +/// Verb list matches D-006's subsystem table: +/// pane.spawn pane.list pane.focus pane.close +/// pane.write pane.resize pane.tail +/// +/// `pane.tail` is a no-op on the command surface — events are pushed +/// over the same socket. The name exists for parity with the CLI +/// (`clide pane tail --events`) which subscribes to the event stream. +library; + +import 'dart:convert'; + +import '../ipc/envelope.dart'; +import '../ipc/schema_v1.dart'; +import '../panes/pane.dart'; +import '../panes/registry.dart'; +import 'dispatcher.dart'; + +void registerPaneCommands(DaemonDispatcher d, PaneRegistry registry) { + d.register('pane.spawn', (req) => _spawn(req, registry)); + d.register('pane.list', (req) => _list(req, registry)); + d.register('pane.close', (req) => _close(req, registry)); + d.register('pane.write', (req) => _write(req, registry)); + d.register('pane.resize', (req) => _resize(req, registry)); + d.register('pane.focus', (req) => _focus(req, registry)); + d.register('pane.tail', (req) => _tail(req, registry)); +} + +IpcResponse _userErr(String id, String message, {String? hint}) => + IpcResponse.err( + id: id, + error: IpcError( + code: IpcExitCode.userError, + kind: IpcErrorKind.userError, + message: message, + hint: hint, + ), + ); + +IpcResponse _notFound(String id, String message) => IpcResponse.err( + id: id, + error: IpcError( + code: IpcExitCode.notFound, + kind: IpcErrorKind.notFound, + message: message, + ), + ); + +Future _spawn(IpcRequest req, PaneRegistry registry) async { + final args = req.args; + final rawArgv = args['argv']; + if (rawArgv is! List || rawArgv.isEmpty) { + return _userErr(req.id, 'argv is required and non-empty'); + } + final argv = rawArgv.whereType().toList(); + if (argv.length != rawArgv.length) { + return _userErr(req.id, 'argv entries must be strings'); + } + + final rawKind = args['kind']; + PaneKind kind; + try { + kind = rawKind is String ? PaneKind.parse(rawKind) : PaneKind.terminal; + } on ArgumentError catch (e) { + return _userErr(req.id, e.message?.toString() ?? 'bad kind'); + } + + final envArg = args['env']; + Map? env; + if (envArg is Map) { + env = { + for (final e in envArg.entries) + '${e.key}': '${e.value}', + }; + } + + try { + final pane = await registry.spawn( + kind: kind, + argv: argv, + cwd: args['cwd'] as String?, + env: env, + cols: (args['cols'] as num?)?.toInt() ?? 80, + rows: (args['rows'] as num?)?.toInt() ?? 24, + title: args['title'] as String?, + ptycPath: (args['ptyc_path'] as String?) ?? 'ptyc', + ); + return IpcResponse.ok(id: req.id, data: pane.toJson()); + } catch (e) { + return IpcResponse.err( + id: req.id, + error: IpcError( + code: IpcExitCode.toolError, + kind: IpcErrorKind.toolError, + message: 'pane.spawn failed: $e', + ), + ); + } +} + +Future _list(IpcRequest req, PaneRegistry registry) async { + return IpcResponse.ok( + id: req.id, + data: {'panes': [for (final p in registry.panes) p.toJson()]}, + ); +} + +Future _close(IpcRequest req, PaneRegistry registry) async { + final id = req.args['id'] as String?; + if (id == null) return _userErr(req.id, 'id is required'); + if (registry.get(id) == null) return _notFound(req.id, 'no such pane: $id'); + await registry.close(id); + return IpcResponse.ok(id: req.id, data: {'id': id}); +} + +Future _write(IpcRequest req, PaneRegistry registry) async { + final id = req.args['id'] as String?; + if (id == null) return _userErr(req.id, 'id is required'); + if (registry.get(id) == null) return _notFound(req.id, 'no such pane: $id'); + + List bytes; + final rawBytes = req.args['bytes_b64']; + final rawText = req.args['text']; + if (rawBytes is String) { + try { + bytes = base64Decode(rawBytes); + } on FormatException { + return _userErr(req.id, 'bytes_b64 is not valid base64'); + } + } else if (rawText is String) { + bytes = utf8.encode(rawText); + } else { + return _userErr(req.id, 'write requires bytes_b64 or text'); + } + + final n = registry.write(id, bytes); + return IpcResponse.ok(id: req.id, data: {'id': id, 'written': n}); +} + +Future _resize(IpcRequest req, PaneRegistry registry) async { + final id = req.args['id'] as String?; + final cols = (req.args['cols'] as num?)?.toInt(); + final rows = (req.args['rows'] as num?)?.toInt(); + if (id == null || cols == null || rows == null) { + return _userErr(req.id, 'id, cols, rows are required'); + } + if (registry.get(id) == null) return _notFound(req.id, 'no such pane: $id'); + registry.resize(id, cols: cols, rows: rows); + return IpcResponse.ok(id: req.id, data: {'id': id, 'cols': cols, 'rows': rows}); +} + +Future _focus(IpcRequest req, PaneRegistry registry) async { + final id = req.args['id'] as String?; + if (id == null) return _userErr(req.id, 'id is required'); + if (registry.get(id) == null) return _notFound(req.id, 'no such pane: $id'); + // Focus is advisory on the daemon side — UIs track their own focus + // state. We just emit the event so subscribers know what changed. + registry.events.emit(IpcEvent( + subsystem: 'pane', + kind: 'pane.focused', + timestamp: DateTime.now().toUtc(), + data: {'id': id}, + )); + return IpcResponse.ok(id: req.id, data: {'id': id}); +} + +Future _tail(IpcRequest req, PaneRegistry registry) async { + // No-op — events are already pushed over the client's socket by the + // server's broadcast. The response just acknowledges that the + // subscription is in place. + return IpcResponse.ok(id: req.id, data: {'subscribed': true}); +} diff --git a/lib/src/ipc/server.dart b/lib/src/ipc/server.dart index c3e83f96..f5d57861 100644 --- a/lib/src/ipc/server.dart +++ b/lib/src/ipc/server.dart @@ -8,8 +8,7 @@ typedef RequestDispatcher = Future Function(IpcRequest request); /// Unix-socket JSON-lines server. Each connection is an independent /// bidirectional line-framed stream: client writes requests, daemon -/// writes responses (and events, later). Tier 0 handles request→response -/// only; event broadcasting lands with the first feature that emits. +/// writes responses + events on the same socket. class DaemonServer { DaemonServer({ required this.socketPath, @@ -22,6 +21,23 @@ class DaemonServer { ServerSocket? _server; final Set _clients = {}; + /// Broadcast [event] to every currently-connected client. + /// + /// Future tuning: per-client subsystem/id filter (`tail --filter + /// pane:p_7`). For Tier 1 every client sees everything. Sockets + /// that error on write are silently dropped; the client's read side + /// will notice the close. + void broadcast(IpcEvent event) { + final line = event.encode(); + for (final c in List.from(_clients)) { + try { + c.writeln(line); + } catch (_) { + _clients.remove(c); + } + } + } + Future start() async { final addr = InternetAddress(socketPath, type: InternetAddressType.unix); try { diff --git a/lib/src/panes/event_sink.dart b/lib/src/panes/event_sink.dart new file mode 100644 index 00000000..56abf53a --- /dev/null +++ b/lib/src/panes/event_sink.dart @@ -0,0 +1,31 @@ +/// Narrow interface through which subsystems emit events. +/// +/// The daemon server implements this (broadcasts to every connected +/// IPC client); tests can provide a recording fake. Keeping the +/// emitter behind an interface means subsystems don't depend on the +/// server package, which keeps the dep graph pointing the right way +/// (server depends on subsystems, not the other way round). +library; + +import '../ipc/envelope.dart'; + +abstract class DaemonEventSink { + void emit(IpcEvent event); +} + +/// In-memory recording sink for tests + for composing multi-sink +/// scenarios (e.g. tee to both the wire and an audit log). +class RecordingEventSink implements DaemonEventSink { + final List events = []; + + @override + void emit(IpcEvent event) => events.add(event); + + /// Convenience: filter to a single subsystem (`pane`, `git`, …). + Iterable ofSubsystem(String subsystem) => + events.where((e) => e.subsystem == subsystem); + + /// Convenience: filter to a specific `type` (`pane.spawned`, …). + Iterable ofKind(String kind) => + events.where((e) => e.kind == kind); +} diff --git a/lib/src/panes/pane.dart b/lib/src/panes/pane.dart new file mode 100644 index 00000000..ae695430 --- /dev/null +++ b/lib/src/panes/pane.dart @@ -0,0 +1,60 @@ +/// A single active pane in the daemon. +/// +/// Owns a [PtySession] plus whatever pane-level metadata the UI + +/// CLI need. The `kind:` field distinguishes general-purpose terminal +/// panes from Claude panes (D-041, not yet landed) from whatever +/// future pane-shaped surface Tier 1+ grows. +library; + +import '../pty/session.dart'; + +/// Kind of a pane. Keep this enum small and explicit — each kind +/// typically pairs with a bundled extension that manages its +/// lifecycle (`builtin.terminal`, `builtin.claude`). +enum PaneKind { + terminal, + claude; + + String get wire => name; + + static PaneKind parse(String s) { + return PaneKind.values.firstWhere( + (v) => v.wire == s, + orElse: () => throw ArgumentError.value(s, 'kind', 'unknown pane kind'), + ); + } +} + +/// A live pane. Thin wrapper over [PtySession] — the registry is what +/// owns the session lifecycle; consumers of this class read state and +/// call [write] / [resize] via the session. +class Pane { + Pane({ + required this.id, + required this.kind, + required this.session, + required this.argv, + this.cwd, + this.title, + }); + + final String id; + final PaneKind kind; + final PtySession session; + final List argv; + final String? cwd; + final String? title; + + int get pid => session.pid; + bool get isClosed => session.isClosed; + + Map toJson() => { + 'id': id, + 'kind': kind.wire, + 'pid': pid, + 'argv': argv, + if (cwd != null) 'cwd': cwd, + if (title != null) 'title': title, + 'closed': isClosed, + }; +} diff --git a/lib/src/panes/registry.dart b/lib/src/panes/registry.dart new file mode 100644 index 00000000..84a34601 --- /dev/null +++ b/lib/src/panes/registry.dart @@ -0,0 +1,129 @@ +/// [PaneRegistry] — daemon-side state for all live panes. +/// +/// Owns the [PtySession] per pane, generates `p_N` ids, and forwards +/// pty output + lifecycle changes as IPC events via a [DaemonEventSink]. +/// Pane commands (pane.spawn / list / write / resize / close) resolve +/// against this registry; extension UIs subscribe to the emitted events. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import '../ipc/envelope.dart'; +import '../pty/session.dart'; +import 'event_sink.dart'; +import 'pane.dart'; + +class PaneRegistry { + PaneRegistry({required this.events}); + + final DaemonEventSink events; + final Map _panes = {}; + final Map> _subs = {}; + int _nextId = 1; + + /// All currently-live panes (not yet closed). + Iterable get panes => _panes.values; + + Pane? get(String id) => _panes[id]; + + /// Spawn a child under a PTY and wire its output to events. + /// + /// [ptycPath] is plumbed through to [PtySession.spawn]; callers that + /// have a dev-built `ptyc/bin/ptyc` or a non-PATH install can point + /// at it explicitly. + Future spawn({ + required PaneKind kind, + required List argv, + String? cwd, + Map? env, + int cols = 80, + int rows = 24, + String? title, + String ptycPath = 'ptyc', + }) async { + final id = 'p_${_nextId++}'; + final session = await PtySession.spawn( + argv: argv, + cwd: cwd, + env: env, + cols: cols, + rows: rows, + ptycPath: ptycPath, + ); + final pane = Pane( + id: id, + kind: kind, + session: session, + argv: argv, + cwd: cwd, + title: title, + ); + _panes[id] = pane; + + _emit('pane.spawned', id, pane.toJson()); + + _subs[id] = session.output.listen( + (bytes) => _emit('pane.output', id, { + 'bytes_b64': base64Encode(bytes), + }), + onDone: () => _onExit(pane), + ); + + return pane; + } + + /// Send bytes to a pane's stdin. + int write(String id, List bytes) { + final p = _panes[id]; + if (p == null || p.isClosed) return 0; + return p.session.write(bytes); + } + + /// Resize a pane + emit `pane.resized`. + void resize(String id, {required int cols, required int rows}) { + final p = _panes[id]; + if (p == null || p.isClosed) return; + p.session.resize(cols: cols, rows: rows); + _emit('pane.resized', id, {'cols': cols, 'rows': rows}); + } + + /// Close a pane + emit `pane.closed`. Idempotent. + Future close(String id) async { + final p = _panes[id]; + if (p == null) return; + await p.session.close(); + await _subs[id]?.cancel(); + _subs.remove(id); + _panes.remove(id); + _emit('pane.closed', id, const {}); + } + + /// Close every pane. Called on daemon shutdown. + Future shutdown() async { + for (final id in List.from(_panes.keys)) { + await close(id); + } + } + + // -- internals ---------------------------------------------------------- + + void _onExit(Pane p) { + if (_panes.containsKey(p.id)) { + _emit('pane.exit', p.id, const {}); + // Don't auto-close — keep the pane entry so `list` can show the + // exited state until the consumer explicitly closes. A future + // tuning knob (keep-vs-reap policy per kind) can change this. + } + } + + void _emit(String kind, String id, Map data) { + events.emit(IpcEvent( + subsystem: 'pane', + kind: kind, + timestamp: DateTime.now().toUtc(), + data: {'id': id, ...data}, + )); + } +} diff --git a/test/daemon/pane_commands_test.dart b/test/daemon/pane_commands_test.dart new file mode 100644 index 00000000..8381c215 --- /dev/null +++ b/test/daemon/pane_commands_test.dart @@ -0,0 +1,122 @@ +/// Tests for the `pane.*` command handlers. +/// +/// Drives the real registry through the dispatcher — that's the +/// integration surface the CLI + Flutter app both hit. Registry-level +/// behaviour is covered more fully in `test/panes/registry_test.dart`. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:clide/clide.dart'; +import 'package:test/test.dart'; + +void main() { + if (!Platform.isLinux && !Platform.isMacOS) return; + + final ptycPath = File('ptyc/bin/ptyc').existsSync() + ? File('ptyc/bin/ptyc').absolute.path + : 'ptyc'; + + group('pane.* dispatch', () { + late DaemonDispatcher dispatcher; + late PaneRegistry registry; + + setUp(() { + final sink = RecordingEventSink(); + registry = PaneRegistry(events: sink); + dispatcher = DaemonDispatcher(); + registerPaneCommands(dispatcher, registry); + }); + + tearDown(() => registry.shutdown()); + + Future call(String cmd, Map args) { + return dispatcher.dispatch(IpcRequest(id: '1', cmd: cmd, args: args)); + } + + test('pane.spawn requires argv', () async { + final r = await call('pane.spawn', const {}); + expect(r.ok, isFalse); + expect(r.error!.kind, 'user_error'); + expect(r.error!.message, contains('argv')); + }); + + test('pane.spawn returns pane metadata', () async { + final r = await call('pane.spawn', { + 'argv': const ['/bin/sh', '-c', 'sleep 0.1'], + 'kind': 'terminal', + 'ptyc_path': ptycPath, + }); + expect(r.ok, isTrue, reason: r.error?.message); + expect(r.data['id'], startsWith('p_')); + expect(r.data['kind'], 'terminal'); + }); + + test('pane.list shows spawned panes', () async { + await call('pane.spawn', { + 'argv': const ['/bin/cat'], + 'ptyc_path': ptycPath, + }); + await call('pane.spawn', { + 'argv': const ['/bin/cat'], + 'kind': 'claude', + 'ptyc_path': ptycPath, + }); + final r = await call('pane.list', const {}); + final panes = (r.data['panes'] as List).cast(); + expect(panes, hasLength(2)); + expect(panes.map((p) => p['kind']), containsAll(['terminal', 'claude'])); + }); + + test('pane.write accepts text or bytes_b64', () async { + final spawn = await call('pane.spawn', { + 'argv': const ['/bin/cat'], + 'ptyc_path': ptycPath, + }); + final id = spawn.data['id']! as String; + + final viaText = await call('pane.write', {'id': id, 'text': 'abc'}); + expect(viaText.ok, isTrue); + expect(viaText.data['written'], greaterThan(0)); + + final viaBase64 = await call('pane.write', { + 'id': id, + 'bytes_b64': base64Encode(utf8.encode('def')), + }); + expect(viaBase64.ok, isTrue); + }); + + test('pane.write on unknown id → not-found', () async { + final r = await call('pane.write', {'id': 'p_404', 'text': 'x'}); + expect(r.ok, isFalse); + expect(r.error!.code, IpcExitCode.notFound); + }); + + test('pane.resize + pane.close + pane.focus round-trip', () async { + final spawn = await call('pane.spawn', { + 'argv': const ['/bin/cat'], + 'ptyc_path': ptycPath, + }); + final id = spawn.data['id']! as String; + + final r1 = await call('pane.resize', {'id': id, 'cols': 100, 'rows': 30}); + expect(r1.ok, isTrue); + + final r2 = await call('pane.focus', {'id': id}); + expect(r2.ok, isTrue); + + final r3 = await call('pane.close', {'id': id}); + expect(r3.ok, isTrue); + + final list = await call('pane.list', const {}); + expect((list.data['panes'] as List), isEmpty); + }); + + test('pane.tail ack is a no-op', () async { + final r = await call('pane.tail', const {}); + expect(r.ok, isTrue); + expect(r.data['subscribed'], isTrue); + }); + }); +} diff --git a/test/panes/registry_test.dart b/test/panes/registry_test.dart new file mode 100644 index 00000000..8544ea85 --- /dev/null +++ b/test/panes/registry_test.dart @@ -0,0 +1,117 @@ +/// Unit tests for [PaneRegistry]. +/// +/// Exercises spawn / list / write / resize / close against the real +/// `ptyc` binary (small enough, and realistic enough, to not be worth +/// mocking). Events are captured via [RecordingEventSink]. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:clide/clide.dart'; +import 'package:test/test.dart'; + +void main() { + if (!Platform.isLinux && !Platform.isMacOS) return; + + final ptycPath = File('ptyc/bin/ptyc').existsSync() + ? File('ptyc/bin/ptyc').absolute.path + : 'ptyc'; + + group('PaneRegistry', () { + late RecordingEventSink sink; + late PaneRegistry registry; + + setUp(() { + sink = RecordingEventSink(); + registry = PaneRegistry(events: sink); + }); + + tearDown(() => registry.shutdown()); + + test('spawn → emits pane.spawned and lists the pane', () async { + final pane = await registry.spawn( + kind: PaneKind.terminal, + argv: const ['/bin/echo', 'hi'], + ptycPath: ptycPath, + ); + + expect(pane.id, startsWith('p_')); + expect(pane.kind, PaneKind.terminal); + expect(registry.panes, contains(pane)); + expect(sink.ofKind('pane.spawned'), hasLength(1)); + final evt = sink.ofKind('pane.spawned').first; + expect(evt.data['id'], pane.id); + }); + + test('output events base64-encode the child bytes', () async { + await registry.spawn( + kind: PaneKind.terminal, + argv: const ['/bin/echo', 'hello-panes'], + ptycPath: ptycPath, + ); + + // /bin/echo closes its pty quickly. Wait briefly for output + + // the resulting pane.exit event to settle. + for (var i = 0; i < 30; i++) { + if (sink.ofKind('pane.output').isNotEmpty && + sink.ofKind('pane.exit').isNotEmpty) break; + await Future.delayed(const Duration(milliseconds: 100)); + } + + final out = sink.ofKind('pane.output').toList(); + expect(out, isNotEmpty); + final decoded = out + .map((e) => utf8.decode(base64Decode(e.data['bytes_b64']! as String))) + .join(); + expect(decoded, contains('hello-panes')); + }); + + test('write + resize emit no spurious events, update state', () async { + final pane = await registry.spawn( + kind: PaneKind.terminal, + argv: const ['/bin/cat'], + ptycPath: ptycPath, + ); + + final writeCount = registry.write(pane.id, utf8.encode('abc')); + expect(writeCount, greaterThan(0)); + + registry.resize(pane.id, cols: 120, rows: 40); + final resized = sink.ofKind('pane.resized').toList(); + expect(resized, hasLength(1)); + expect(resized.single.data['cols'], 120); + expect(resized.single.data['rows'], 40); + }); + + test('close is idempotent + emits pane.closed once', () async { + final pane = await registry.spawn( + kind: PaneKind.terminal, + argv: const ['/bin/cat'], + ptycPath: ptycPath, + ); + + await registry.close(pane.id); + await registry.close(pane.id); // second call: no-op + + expect(registry.get(pane.id), isNull); + expect(sink.ofKind('pane.closed'), hasLength(1)); + }); + + test('close on unknown id does nothing', () async { + await registry.close('p_nonexistent'); + expect(sink.ofKind('pane.closed'), isEmpty); + }); + + test('claude kind round-trips on the wire', () async { + final pane = await registry.spawn( + kind: PaneKind.claude, + argv: const ['/bin/sh', '-c', 'exit 0'], + ptycPath: ptycPath, + ); + expect(pane.kind, PaneKind.claude); + expect(pane.toJson()['kind'], 'claude'); + }); + }); +}