add pane subsystem to the daemon + IPC event broadcast

Implements the Tier-1 pane subsystem from D-006: spawn / list / focus /
close / write / resize / tail commands, plus pane.spawned / output /
exit / resized / focused / closed events. PaneRegistry owns per-pane
PtySession lifecycles and id generation (p_N); a DaemonEventSink seam
keeps pane code decoupled from the IPC server package.

DaemonServer.broadcast() fans events out to every connected client.
Per-client subsystem/id filtering (`tail --filter pane:p_7`) is
deferred — Tier 1 broadcasts everything and the subscriber discards.

Panes carry a `kind:` field (terminal | claude). Step 7 (builtin.claude)
adds the claude-specific pane flow on top of this generic substrate —
the subsystem itself stays neutral.

14 new core tests: registry unit coverage (spawn → pane.spawned event,
output → base64 events, write/resize/close round-trips, idempotent
close, claude kind on the wire) plus dispatcher coverage (argv
validation, unknown-id → not-found, text vs bytes_b64, etc). All 37
core tests pass in ~3s under test-core.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-04-22 09:05:51 +02:00
co-authored by Claude
parent edd8a20e0d
commit 3715a2e191
10 changed files with 682 additions and 3 deletions
+12
View File
@@ -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
+16 -1
View File
@@ -67,10 +67,13 @@ Matches ADR 0006's exit-code contract:
Future<void> _runDaemon(List<String> 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>();
void shutdown(ProcessSignal sig) {
@@ -85,10 +88,22 @@ Future<void> _runDaemon(List<String> 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<void> _runCli(
String cmd,
List<String> args, {
+4
View File
@@ -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';
+173
View File
@@ -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<IpcResponse> _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<String>().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<String, String>? 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<IpcResponse> _list(IpcRequest req, PaneRegistry registry) async {
return IpcResponse.ok(
id: req.id,
data: {'panes': [for (final p in registry.panes) p.toJson()]},
);
}
Future<IpcResponse> _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<IpcResponse> _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<int> 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<IpcResponse> _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<IpcResponse> _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<IpcResponse> _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});
}
+18 -2
View File
@@ -8,8 +8,7 @@ typedef RequestDispatcher = Future<IpcResponse> 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<Socket> _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<Socket>.from(_clients)) {
try {
c.writeln(line);
} catch (_) {
_clients.remove(c);
}
}
}
Future<void> start() async {
final addr = InternetAddress(socketPath, type: InternetAddressType.unix);
try {
+31
View File
@@ -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<IpcEvent> events = [];
@override
void emit(IpcEvent event) => events.add(event);
/// Convenience: filter to a single subsystem (`pane`, `git`, …).
Iterable<IpcEvent> ofSubsystem(String subsystem) =>
events.where((e) => e.subsystem == subsystem);
/// Convenience: filter to a specific `type` (`pane.spawned`, …).
Iterable<IpcEvent> ofKind(String kind) =>
events.where((e) => e.kind == kind);
}
+60
View File
@@ -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<String> argv;
final String? cwd;
final String? title;
int get pid => session.pid;
bool get isClosed => session.isClosed;
Map<String, Object?> toJson() => {
'id': id,
'kind': kind.wire,
'pid': pid,
'argv': argv,
if (cwd != null) 'cwd': cwd,
if (title != null) 'title': title,
'closed': isClosed,
};
}
+129
View File
@@ -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<String, Pane> _panes = {};
final Map<String, StreamSubscription<Uint8List>> _subs = {};
int _nextId = 1;
/// All currently-live panes (not yet closed).
Iterable<Pane> 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<Pane> spawn({
required PaneKind kind,
required List<String> argv,
String? cwd,
Map<String, String>? 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<int> 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<void> 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<void> shutdown() async {
for (final id in List<String>.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<String, Object?> data) {
events.emit(IpcEvent(
subsystem: 'pane',
kind: kind,
timestamp: DateTime.now().toUtc(),
data: {'id': id, ...data},
));
}
}
+122
View File
@@ -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<IpcResponse> call(String cmd, Map<String, Object?> 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<Map>();
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);
});
});
}
+117
View File
@@ -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<void>.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');
});
});
}