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:
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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},
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user