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
+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},
));
}
}