diff --git a/apps/mobile_app/lib/features/projects/project_list_page.dart b/apps/mobile_app/lib/features/projects/project_list_page.dart index d71b33c..efe0ef8 100644 --- a/apps/mobile_app/lib/features/projects/project_list_page.dart +++ b/apps/mobile_app/lib/features/projects/project_list_page.dart @@ -64,6 +64,7 @@ class _ProjectListPageState extends ConsumerState { if (_agentUrlController.text != restoredUri.toString()) { _agentUrlController.text = restoredUri.toString(); } + await _reloadProjects(); return; } diff --git a/apps/mobile_app/lib/features/terminal/terminal_page.dart b/apps/mobile_app/lib/features/terminal/terminal_page.dart index 8ff1887..b74e845 100644 --- a/apps/mobile_app/lib/features/terminal/terminal_page.dart +++ b/apps/mobile_app/lib/features/terminal/terminal_page.dart @@ -18,6 +18,7 @@ import 'terminal_diagnostic_log.dart'; import 'history_window.dart'; import 'repeatable_terminal_key_button.dart'; import 'terminal_interaction_controller.dart'; +import 'terminal_restore_payload.dart'; import 'terminal_session_coordinator.dart'; import 'terminal_socket_session.dart'; @@ -101,8 +102,10 @@ class _TerminalPageState extends ConsumerState Timer? _historySeedTimer; String? _pendingHistorySeed; bool _receivedSocketFrame = false; + bool _receivedRestorePayload = false; bool _historySeeded = false; bool _awaitingAttachReplayFrame = true; + bool _awaitingReconnectRestore = false; bool _shouldReconnectOnResume = false; TerminalConnectionState? _lastConnectionState; @@ -118,6 +121,7 @@ class _TerminalPageState extends ConsumerState baseUri: widget.agentBaseUri, diagnosticLog: _diagnosticLog, onFrame: _handleTerminalFrame, + onRestore: _handleRestorePayload, onHistoryLoaded: _handleHistoryLoaded, viewportProvider: () => TerminalViewport( columns: terminal.viewWidth, @@ -275,10 +279,27 @@ class _TerminalPageState extends ConsumerState } _receivedSocketFrame = true; + _awaitingReconnectRestore = false; _cancelHistorySeedTimer(); terminal.write(frame); } + void _handleRestorePayload(TerminalRestorePayload restore) { + _awaitingAttachReplayFrame = false; + _receivedSocketFrame = true; + _receivedRestorePayload = true; + _awaitingReconnectRestore = false; + _historySeeded = false; + _cancelHistorySeedTimer(); + _resetTerminalForReplay(); + final combined = restore.screenText + restore.pendingInput; + if (combined.isEmpty) { + return; + } + + terminal.write(combined); + } + void _handleHistoryLoaded(HistoryWindow history) { if (history.lines.isEmpty) { _pendingHistorySeed = null; @@ -295,8 +316,14 @@ class _TerminalPageState extends ConsumerState if (connectionState == TerminalConnectionState.connecting || connectionState == TerminalConnectionState.reconnecting) { _awaitingAttachReplayFrame = true; + _receivedRestorePayload = false; if (connectionState == TerminalConnectionState.reconnecting) { + _awaitingReconnectRestore = true; _resetTerminalForReplay(); + _historySeeded = false; + _receivedSocketFrame = false; + } else { + _awaitingReconnectRestore = false; } } _lastConnectionState = connectionState; @@ -306,6 +333,16 @@ class _TerminalPageState extends ConsumerState } void _scheduleHistorySeedIfNeeded() { + if (_receivedRestorePayload) { + _cancelHistorySeedTimer(); + return; + } + + if (_awaitingReconnectRestore) { + _cancelHistorySeedTimer(); + return; + } + if (_historySeeded || _receivedSocketFrame || _connectionState != TerminalConnectionState.connected) { diff --git a/apps/mobile_app/lib/features/terminal/terminal_restore_payload.dart b/apps/mobile_app/lib/features/terminal/terminal_restore_payload.dart new file mode 100644 index 0000000..93f55b9 --- /dev/null +++ b/apps/mobile_app/lib/features/terminal/terminal_restore_payload.dart @@ -0,0 +1,22 @@ +class TerminalRestorePayload { + const TerminalRestorePayload({ + required this.sessionId, + required this.sequence, + required this.screenText, + required this.pendingInput, + }); + + final String sessionId; + final int sequence; + final String screenText; + final String pendingInput; + + factory TerminalRestorePayload.fromJson(Map json) { + return TerminalRestorePayload( + sessionId: json['sessionId'] as String, + sequence: json['sequence'] as int, + screenText: (json['screenText'] as String?) ?? '', + pendingInput: (json['pendingInput'] as String?) ?? '', + ); + } +} diff --git a/apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart b/apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart index 5b70adc..e961783 100644 --- a/apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart +++ b/apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart @@ -7,6 +7,7 @@ import '../sessions/session.dart'; import 'terminal_diagnostic_log.dart'; import 'history_window.dart'; import 'terminal_interaction_controller.dart'; +import 'terminal_restore_payload.dart'; import 'terminal_socket_session.dart'; typedef CancelReconnect = void Function(); @@ -35,6 +36,7 @@ class TerminalSessionCoordinator extends ChangeNotifier { required this.session, required this.sessionFactory, required this.onFrame, + required this.onRestore, required this.viewportProvider, Uri? baseUri, this.onHistoryLoaded, @@ -60,6 +62,7 @@ class TerminalSessionCoordinator extends ChangeNotifier { final Session session; final TerminalSessionFactory sessionFactory; final void Function(String frame) onFrame; + final void Function(TerminalRestorePayload restore) onRestore; final TerminalViewport Function() viewportProvider; final Uri baseUri; final void Function(HistoryWindow history)? onHistoryLoaded; @@ -133,6 +136,13 @@ class TerminalSessionCoordinator extends ChangeNotifier { _handleFrame(chunk); }, + onRestore: (restore) { + if (!_isCurrentSession(socketSession, sessionGeneration)) { + return; + } + + _handleRestore(restore); + }, onDisconnected: () { if (!_isCurrentSession(socketSession, sessionGeneration)) { return; @@ -280,6 +290,14 @@ class TerminalSessionCoordinator extends ChangeNotifier { onFrame(chunk); } + void _handleRestore(TerminalRestorePayload restore) { + diagnosticLog?.add( + 'socket.restore.rx', + 'sequence=${restore.sequence} pending=${restore.pendingInput.length}', + ); + onRestore(restore); + } + Future _loadHistory() async { try { final payload = await apiClient.getSessionHistory( diff --git a/apps/mobile_app/lib/features/terminal/terminal_socket_session.dart b/apps/mobile_app/lib/features/terminal/terminal_socket_session.dart index dc48d61..7899539 100644 --- a/apps/mobile_app/lib/features/terminal/terminal_socket_session.dart +++ b/apps/mobile_app/lib/features/terminal/terminal_socket_session.dart @@ -7,6 +7,7 @@ import 'package:web_socket_channel/web_socket_channel.dart'; import '../../core/network/agent_api_client.dart'; import '../../core/network/agent_socket_client.dart'; import '../sessions/session.dart'; +import 'terminal_restore_payload.dart'; typedef TerminalSocketTransportFactory = TerminalSocketTransport Function(Uri uri); @@ -56,6 +57,7 @@ class TerminalSocketSession { Future connect({ required void Function(String frame) onFrame, + required void Function(TerminalRestorePayload restore) onRestore, void Function()? onDisconnected, }) async { if (_transport != null || _subscription != null) { @@ -82,6 +84,10 @@ class TerminalSocketSession { return; } + if (_handleRestoreFrame(message, onRestore)) { + return; + } + onFrame(message); }, onError: (error, stackTrace) { @@ -175,6 +181,23 @@ class TerminalSocketSession { return false; } + bool _handleRestoreFrame( + String frame, + void Function(TerminalRestorePayload restore) onRestore, + ) { + try { + final decoded = jsonDecode(frame); + if (decoded is Map && decoded['type'] == 'restore') { + onRestore( + TerminalRestorePayload.fromJson(Map.from(decoded)), + ); + return true; + } + } catch (_) {} + + return false; + } + void _handleTransportClosed(TerminalSocketTransport? transport) { if (transport == null) { _subscription = null; diff --git a/apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart b/apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart index d787c94..4982fbe 100644 --- a/apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart +++ b/apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart @@ -5,6 +5,7 @@ import 'package:term_remote_ctl/core/network/agent_api_client.dart'; import 'package:term_remote_ctl/core/network/agent_socket_client.dart'; import 'package:term_remote_ctl/features/sessions/session.dart'; import 'package:term_remote_ctl/features/terminal/terminal_interaction_controller.dart'; +import 'package:term_remote_ctl/features/terminal/terminal_restore_payload.dart'; import 'package:term_remote_ctl/features/terminal/terminal_session_coordinator.dart'; import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart'; @@ -26,6 +27,7 @@ void main() { session: session, sessionFactory: sessionFactory.create, onFrame: (_) {}, + onRestore: (_) {}, viewportProvider: () => const TerminalViewport(columns: 132, rows: 40), ); @@ -63,6 +65,7 @@ void main() { session: session, sessionFactory: sessionFactory.create, onFrame: (_) {}, + onRestore: (_) {}, viewportProvider: () => const TerminalViewport(columns: 80, rows: 24), reconnectScheduler: reconnectScheduler.schedule, ); @@ -101,6 +104,7 @@ void main() { session: session, sessionFactory: sessionFactory.create, onFrame: (_) {}, + onRestore: (_) {}, viewportProvider: () => const TerminalViewport(columns: 80, rows: 24), reconnectScheduler: reconnectScheduler.schedule, ); @@ -150,6 +154,7 @@ void main() { session: session, sessionFactory: sessionFactory.create, onFrame: (_) {}, + onRestore: (_) {}, viewportProvider: () => const TerminalViewport(columns: 80, rows: 24), ); @@ -177,6 +182,7 @@ void main() { session: session, sessionFactory: sessionFactory.create, onFrame: (_) {}, + onRestore: (_) {}, viewportProvider: () => const TerminalViewport(columns: 80, rows: 24), ); @@ -207,6 +213,7 @@ void main() { session: session, sessionFactory: sessionFactory.create, onFrame: receivedFrames.add, + onRestore: (_) {}, viewportProvider: () => const TerminalViewport(columns: 80, rows: 24), ); @@ -251,6 +258,7 @@ void main() { session: session, sessionFactory: sessionFactory.create, onFrame: (_) {}, + onRestore: (_) {}, viewportProvider: () => const TerminalViewport(columns: 80, rows: 24), resizeScheduler: resizeScheduler.schedule, ); @@ -293,6 +301,7 @@ void main() { session: session, sessionFactory: sessionFactory.create, onFrame: receivedFrames.add, + onRestore: (_) {}, viewportProvider: () => const TerminalViewport(columns: 80, rows: 24), ); @@ -311,6 +320,41 @@ void main() { expect(receivedFrames, ['fresh-frame']); }, ); + + test('restore payloads are forwarded to the UI callback', () async { + final controller = TerminalInteractionController(); + final apiClient = _FakeAgentApiClient(); + final sessionFactory = _FakeTerminalSessionFactory(); + final session = Session( + sessionId: 'abc', + name: 'codex-main', + status: 'idle', + ); + final restores = []; + final coordinator = TerminalSessionCoordinator( + controller: controller, + apiClient: apiClient, + session: session, + sessionFactory: sessionFactory.create, + onFrame: (_) {}, + onRestore: restores.add, + viewportProvider: () => const TerminalViewport(columns: 80, rows: 24), + ); + + await coordinator.start(); + + sessionFactory.createdSessions.single.emitRestore( + const TerminalRestorePayload( + sessionId: 'abc', + sequence: 7, + screenText: 'PS> gi', + pendingInput: 't status', + ), + ); + + expect(restores, hasLength(1)); + expect(restores.single.pendingInput, 't status'); + }); } class _FakeAgentApiClient extends AgentApiClient { @@ -374,13 +418,16 @@ class _FakeTerminalSocketSession extends TerminalSocketSession { void Function(String frame)? _onFrame; void Function()? _onDisconnected; bool _isDisconnected = false; + void Function(TerminalRestorePayload restore)? _onRestore; @override Future connect({ required void Function(String frame) onFrame, + required void Function(TerminalRestorePayload restore) onRestore, void Function()? onDisconnected, }) { _onFrame = onFrame; + _onRestore = onRestore; _onDisconnected = onDisconnected; if (autoConnect && !_connectCompleter.isCompleted) { _connectCompleter.complete(); @@ -413,6 +460,10 @@ class _FakeTerminalSocketSession extends TerminalSocketSession { _onFrame?.call(frame); } + void emitRestore(TerminalRestorePayload restore) { + _onRestore?.call(restore); + } + void disconnect() { _isDisconnected = true; _onDisconnected?.call(); diff --git a/apps/mobile_app/test/features/terminal/terminal_socket_session_test.dart b/apps/mobile_app/test/features/terminal/terminal_socket_session_test.dart index 8dd6e27..c7d89f1 100644 --- a/apps/mobile_app/test/features/terminal/terminal_socket_session_test.dart +++ b/apps/mobile_app/test/features/terminal/terminal_socket_session_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:term_remote_ctl/core/network/agent_socket_client.dart'; +import 'package:term_remote_ctl/features/terminal/terminal_restore_payload.dart'; import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart'; void main() { @@ -15,7 +16,10 @@ void main() { final frames = []; var completed = false; - final connectFuture = session.connect(onFrame: frames.add).then((_) { + final connectFuture = session.connect( + onFrame: frames.add, + onRestore: (_) {}, + ).then((_) { completed = true; }); await Future.delayed(Duration.zero); @@ -48,7 +52,7 @@ void main() { transportFactory: (_) => transport, ); - final connectFuture = session.connect(onFrame: (_) {}); + final connectFuture = session.connect(onFrame: (_) {}, onRestore: (_) {}); await transport.close(); await expectLater(connectFuture, throwsStateError); @@ -62,7 +66,7 @@ void main() { transportFactory: (_) => transport, ); - final connectFuture = session.connect(onFrame: (_) {}); + final connectFuture = session.connect(onFrame: (_) {}, onRestore: (_) {}); await Future.delayed(Duration.zero); transport.emit('{"type":"attached","sessionId":"session-123"}'); await connectFuture; @@ -84,7 +88,7 @@ void main() { transportFactory: (_) => transport, ); - final connectFuture = session.connect(onFrame: (_) {}); + final connectFuture = session.connect(onFrame: (_) {}, onRestore: (_) {}); await Future.delayed(Duration.zero); transport.emit('{"type":"attached","sessionId":"session-123"}'); await connectFuture; @@ -110,6 +114,7 @@ void main() { var disconnectCount = 0; final connectFuture = session.connect( onFrame: (_) {}, + onRestore: (_) {}, onDisconnected: () { disconnectCount += 1; }, @@ -132,7 +137,7 @@ void main() { transportFactory: (_) => transport, ); - final connectFuture = session.connect(onFrame: (_) {}); + final connectFuture = session.connect(onFrame: (_) {}, onRestore: (_) {}); await Future.delayed(Duration.zero); transport.emit('{"type":"attached","sessionId":"session-123"}'); await connectFuture; @@ -145,6 +150,39 @@ void main() { TerminalSocketDispatchResult.noTransport, ); }); + + test('connect routes restore frames to onRestore before live output', () async { + final transport = _FakeTerminalSocketTransport(); + final session = TerminalSocketSession( + sessionId: 'session-123', + socketClient: AgentSocketClient(Uri.parse('https://host:9443')), + transportFactory: (_) => transport, + ); + + final frames = []; + final restores = []; + final connectFuture = session.connect( + onFrame: frames.add, + onRestore: restores.add, + ); + await Future.delayed(Duration.zero); + + transport.emit('{"type":"attached","sessionId":"session-123"}'); + await connectFuture; + + transport.emit( + '{"type":"restore","sessionId":"session-123","sequence":4,"screenText":"PS> gi","pendingInput":"t status"}', + ); + transport.emit('live-output'); + await Future.delayed(Duration.zero); + + expect(restores, hasLength(1)); + expect(restores.single.sessionId, 'session-123'); + expect(restores.single.sequence, 4); + expect(restores.single.screenText, 'PS> gi'); + expect(restores.single.pendingInput, 't status'); + expect(frames, ['live-output']); + }); } class _FakeTerminalSocketTransport implements TerminalSocketTransport { diff --git a/apps/mobile_app/test/terminal_session_coordinator_diagnostics_test.dart b/apps/mobile_app/test/terminal_session_coordinator_diagnostics_test.dart index d910a9f..dca11ce 100644 --- a/apps/mobile_app/test/terminal_session_coordinator_diagnostics_test.dart +++ b/apps/mobile_app/test/terminal_session_coordinator_diagnostics_test.dart @@ -7,6 +7,7 @@ import 'package:term_remote_ctl/features/sessions/session.dart'; import 'package:term_remote_ctl/features/terminal/history_window.dart'; import 'package:term_remote_ctl/features/terminal/terminal_diagnostic_log.dart'; import 'package:term_remote_ctl/features/terminal/terminal_interaction_controller.dart'; +import 'package:term_remote_ctl/features/terminal/terminal_restore_payload.dart'; import 'package:term_remote_ctl/features/terminal/terminal_session_coordinator.dart'; import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart'; @@ -26,6 +27,7 @@ void main() { baseUri: Uri.parse('https://host.example:9443'), diagnosticLog: diagnosticLog, onFrame: (_) {}, + onRestore: (_) {}, viewportProvider: () => const TerminalViewport(columns: 120, rows: 30), ); @@ -60,6 +62,7 @@ void main() { baseUri: Uri.parse('https://host.example:9443'), diagnosticLog: diagnosticLog, onFrame: (_) {}, + onRestore: (_) {}, viewportProvider: () => const TerminalViewport(columns: 120, rows: 30), ); @@ -109,6 +112,7 @@ class _RecordingTerminalSocketSession extends TerminalSocketSession { @override Future connect({ required void Function(String frame) onFrame, + required void Function(TerminalRestorePayload restore) onRestore, void Function()? onDisconnected, }) async {} diff --git a/apps/mobile_app/test/widget_test.dart b/apps/mobile_app/test/widget_test.dart index dd677c6..5eb6858 100644 --- a/apps/mobile_app/test/widget_test.dart +++ b/apps/mobile_app/test/widget_test.dart @@ -87,6 +87,43 @@ void main() { }, ); + testWidgets( + 'restoring the same persisted agent URL retries project loading once', + (tester) async { + final projectRepository = _RecoveringProjectRepository(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()), + agentBaseUriStorageProvider.overrideWithValue( + _MemoryAgentBaseUriStorage( + Uri.parse('http://100.81.30.82:5067'), + ), + ), + projectRepositoryProvider.overrideWithValue(projectRepository), + sessionRepositoryProvider.overrideWithValue(_FakeSessionRepository()), + presetRepositoryProvider.overrideWithValue( + _MemoryPresetRepository(const []), + ), + terminalSocketSessionFactoryProvider.overrideWithValue( + TerminalSocketSessionFactory( + transportFactory: (_) => + _FakeTerminalSocketTransport(autoAttach: true), + ), + ), + ], + child: const TermRemoteCtlApp(), + ), + ); + await tester.pumpAndSettle(); + + expect(projectRepository.listProjectsCallCount, 2); + expect(find.text('Could not load projects'), findsNothing); + expect(find.text('codex-main'), findsOneWidget); + }, + ); + testWidgets('project card opens a terminal without an extra prompt', ( tester, ) async { @@ -237,6 +274,33 @@ void main() { expect(find.text('Recent sessions'), findsOneWidget); }); + testWidgets( + 'terminal reconnect applies restore payload before live frames', + (tester) async { + final transportFactory = _QueuedTerminalSocketTransportFactory( + connectionStartupFrames: const [ + [ + _StartupFrame('{"type":"attached","sessionId":"session-1"}'), + _StartupFrame( + '{"type":"restore","sessionId":"session-1","sequence":4,"screenText":"PS> gi","pendingInput":"t status"}', + ), + ], + ], + ); + + await _pumpTerminalPage( + tester, + session: _session('session-1', 'codex-main'), + socketFactory: TerminalSocketSessionFactory( + transportFactory: transportFactory.create, + ), + ); + + final terminal = tester.widget(find.byType(TerminalView)).terminal; + expect(terminal.buffer.getText(), contains('PS> git status')); + }, + ); + testWidgets( 'terminal page keeps the command deck above the bottom safe area', (tester) async { @@ -775,6 +839,60 @@ void main() { }, ); + testWidgets( + 'terminal reconnect restores pending input without history seed fallback', + (tester) async { + final transportFactory = _QueuedTerminalSocketTransportFactory( + connectionStartupFrames: [ + const [ + _StartupFrame('{"type":"attached","sessionId":"session-1"}'), + _StartupFrame('initial-output'), + ], + const [ + _StartupFrame('{"type":"attached","sessionId":"session-1"}'), + _StartupFrame( + '{"type":"restore","sessionId":"session-1","sequence":7,"screenText":"PS> gi","pendingInput":"t status"}', + delay: Duration(milliseconds: 220), + ), + ], + ], + ); + + await _pumpTerminalPage( + tester, + session: _session('session-1', 'codex-main'), + apiClient: _SequencedHistoryAgentApiClient( + responses: [ + { + 'sessionId': 'session-1', + 'lines': ['stale-history'], + 'hasMoreAbove': false, + }, + ], + ), + socketFactory: TerminalSocketSessionFactory( + transportFactory: transportFactory.create, + ), + ); + + await transportFactory.createdTransports.first.close(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(milliseconds: 150)); + + var terminal = tester + .widget(find.byType(TerminalView)) + .terminal; + expect(terminal.buffer.getText(), isNot(contains('stale-history'))); + + await tester.pump(const Duration(milliseconds: 120)); + await tester.pumpAndSettle(); + + terminal = tester.widget(find.byType(TerminalView)).terminal; + expect(terminal.buffer.getText(), contains('PS> git status')); + }, + ); + testWidgets( 're-entering an existing session restores the terminal cursor to the last line', (tester) async { diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptions.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptions.cs index e9632bb..644fa0d 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptions.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptions.cs @@ -16,6 +16,8 @@ public sealed class AgentOptions public int RingBufferLineLimit { get; set; } + public int SessionJournalRetentionDays { get; set; } = 7; + public bool HasHttpsEndpoint => HttpsPort > 0; public bool HasHttpEndpoint => HttpPort > 0; diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionIoEvent.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionIoEvent.cs new file mode 100644 index 0000000..b06ea86 --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionIoEvent.cs @@ -0,0 +1,8 @@ +namespace TermRemoteCtl.Agent.History; + +public sealed record SessionIoEvent( + string SessionId, + long Sequence, + string Kind, + string Payload, + DateTimeOffset TimestampUtc); diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionIoJournalStore.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionIoJournalStore.cs new file mode 100644 index 0000000..19ebd38 --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionIoJournalStore.cs @@ -0,0 +1,35 @@ +using System.Text; +using System.Text.Json; + +namespace TermRemoteCtl.Agent.History; + +public sealed class SessionIoJournalStore +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + private readonly string _sessionRoot; + + public SessionIoJournalStore(string rootPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); + + _sessionRoot = Path.Combine(rootPath, "sessions"); + Directory.CreateDirectory(_sessionRoot); + } + + public async Task AppendAsync(SessionIoEvent ioEvent, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(ioEvent); + + var filePath = Path.Combine(_sessionRoot, $"{ioEvent.SessionId}.io.jsonl"); + var line = JsonSerializer.Serialize(ioEvent, SerializerOptions) + Environment.NewLine; + await File.AppendAllTextAsync( + filePath, + line, + new UTF8Encoding(false), + cancellationToken).ConfigureAwait(false); + } +} diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs index 158aa15..960671a 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs @@ -30,6 +30,11 @@ builder.Services.AddSingleton(serviceProvider => var options = serviceProvider.GetRequiredService>().Value; return new SessionHistoryStore(options.DataRoot); }); +builder.Services.AddSingleton(serviceProvider => +{ + var options = serviceProvider.GetRequiredService>().Value; + return new SessionIoJournalStore(options.DataRoot); +}); // Task 2 uses ASP.NET Core's local development certificate so HttpsPort is a truthful HTTPS listener. builder.WebHost.ConfigureKestrel(kestrel => { diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs index 2ec398a..a731757 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs @@ -4,6 +4,7 @@ using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using TermRemoteCtl.Agent.Configuration; +using TermRemoteCtl.Agent.History; using TermRemoteCtl.Agent.Sessions; using TermRemoteCtl.Agent.Terminal; @@ -43,6 +44,7 @@ public static class TerminalWebSocketHandler } var host = context.RequestServices.GetRequiredService(); + var journalStore = context.RequestServices.GetRequiredService(); var diagnostics = context.RequestServices.GetRequiredService(); var options = context.RequestServices.GetRequiredService>().Value; using var socket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false); @@ -75,18 +77,47 @@ public static class TerminalWebSocketHandler try { - var replay = registry.GetReplaySnapshot(sessionId); - host.OutputReceived += HandleOutput; + await journalStore.AppendAsync( + new SessionIoEvent( + sessionId, + registry.NextSequence(sessionId), + "attach", + string.Empty, + DateTimeOffset.UtcNow), + context.RequestAborted).ConfigureAwait(false); + var restore = registry.GetRestoreSnapshot(sessionId); await SendJsonAsync(socket, new TerminalAttachResponse(sessionId), sendGate, context.RequestAborted).ConfigureAwait(false); - if (!string.IsNullOrEmpty(replay)) - { - await SendTextAsync(socket, replay, sendGate, context.RequestAborted).ConfigureAwait(false); - } - await ReceiveLoopAsync(context, socket, host, diagnostics, sessionId).ConfigureAwait(false); + await SendJsonAsync( + socket, + new TerminalRestoreResponse( + restore.SessionId, + restore.Sequence, + restore.ScreenText, + restore.PendingInput, + restore.CursorRow, + restore.CursorColumn), + sendGate, + context.RequestAborted).ConfigureAwait(false); + host.OutputReceived += HandleOutput; + await ReceiveLoopAsync(context, socket, host, journalStore, diagnostics, sessionId).ConfigureAwait(false); } finally { host.OutputReceived -= HandleOutput; + try + { + await journalStore.AppendAsync( + new SessionIoEvent( + sessionId, + registry.NextSequence(sessionId), + "detach", + string.Empty, + DateTimeOffset.UtcNow), + CancellationToken.None).ConfigureAwait(false); + } + catch + { + } } } @@ -94,6 +125,7 @@ public static class TerminalWebSocketHandler HttpContext context, WebSocket socket, ISessionHost host, + SessionIoJournalStore journalStore, ITerminalDiagnosticsSink diagnostics, string sessionId) { @@ -125,6 +157,7 @@ public static class TerminalWebSocketHandler Encoding.UTF8.GetString(message.ToArray()), context.RequestServices.GetRequiredService(), host, + journalStore, diagnostics, sessionId, context.RequestAborted).ConfigureAwait(false); @@ -135,6 +168,7 @@ public static class TerminalWebSocketHandler string payload, SessionRegistry registry, ISessionHost host, + SessionIoJournalStore journalStore, ITerminalDiagnosticsSink diagnostics, string sessionId, CancellationToken cancellationToken) @@ -165,6 +199,14 @@ public static class TerminalWebSocketHandler if (!string.IsNullOrEmpty(message.Input)) { registry.RecordInputEcho(sessionId, message.Input); + await journalStore.AppendAsync( + new SessionIoEvent( + sessionId, + registry.NextSequence(sessionId), + "input", + message.Input, + DateTimeOffset.UtcNow), + cancellationToken).ConfigureAwait(false); diagnostics.Record("backend.input.received", sessionId, SanitizeDiagnosticText(message.Input)); await host.WriteInputAsync(sessionId, message.Input, cancellationToken).ConfigureAwait(false); } @@ -174,6 +216,14 @@ public static class TerminalWebSocketHandler if (message.Columns is > 0 && message.Rows is > 0) { + await journalStore.AppendAsync( + new SessionIoEvent( + sessionId, + registry.NextSequence(sessionId), + "resize", + $"{message.Columns.Value}x{message.Rows.Value}", + DateTimeOffset.UtcNow), + cancellationToken).ConfigureAwait(false); await host.ResizeAsync(sessionId, message.Columns.Value, message.Rows.Value, cancellationToken).ConfigureAwait(false); } } @@ -185,7 +235,7 @@ public static class TerminalWebSocketHandler private static async Task SendJsonAsync( WebSocket socket, - TerminalAttachResponse response, + object response, SemaphoreSlim sendGate, CancellationToken cancellationToken) { @@ -228,6 +278,15 @@ public static class TerminalWebSocketHandler private sealed record TerminalAttachResponse(string SessionId, string Type = "attached"); + private sealed record TerminalRestoreResponse( + string SessionId, + long Sequence, + string ScreenText, + string PendingInput, + int? CursorRow, + int? CursorColumn, + string Type = "restore"); + private sealed record TerminalClientMessage( string Type, string? SessionId, diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs index 42f25fa..8a90242 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs @@ -12,6 +12,7 @@ public sealed class SessionRegistry private readonly ConcurrentDictionary _historyBySession = new(); private readonly ConcurrentDictionary _replayBySession = new(); private readonly ConcurrentDictionary _pendingInputEchoBySession = new(); + private readonly ConcurrentDictionary _sequenceBySession = new(); private readonly SessionHistoryStore _historyStore; private readonly int _ringBufferLineLimit; @@ -42,6 +43,7 @@ public sealed class SessionRegistry _historyBySession[record.SessionId] = new TerminalRingBuffer(_ringBufferLineLimit); _replayBySession[record.SessionId] = new TerminalReplayBuffer(ReplayCharacterLimit); _pendingInputEchoBySession[record.SessionId] = new PendingInputEchoTracker(); + _sequenceBySession[record.SessionId] = 0; return record; } @@ -105,6 +107,7 @@ public sealed class SessionRegistry sessionId, _ => new PendingInputEchoTracker()); pendingInputEcho.ObserveOutput(chunk); + NextSequence(sessionId); _records[sessionId] = record with { UpdatedAtUtc = DateTimeOffset.UtcNow }; await _historyStore.AppendAsync(sessionId, chunk, cancellationToken).ConfigureAwait(false); } @@ -123,6 +126,7 @@ public sealed class SessionRegistry sessionId, _ => new PendingInputEchoTracker()); pendingInputEcho.Record(input); + NextSequence(sessionId); _records[sessionId] = record with { UpdatedAtUtc = DateTimeOffset.UtcNow }; } @@ -166,6 +170,44 @@ public sealed class SessionRegistry return string.Concat(replay.GetSnapshot(), pendingInputEcho.GetVisibleSuffix()); } + public SessionRestoreSnapshot GetRestoreSnapshot(string sessionId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + + if (!_records.ContainsKey(sessionId)) + { + throw new KeyNotFoundException($"Session '{sessionId}' was not found."); + } + + var replay = _replayBySession.GetOrAdd( + sessionId, + _ => new TerminalReplayBuffer(ReplayCharacterLimit)); + var pendingInputEcho = _pendingInputEchoBySession.GetOrAdd( + sessionId, + _ => new PendingInputEchoTracker()); + var sequence = _sequenceBySession.GetOrAdd(sessionId, 1); + + return new SessionRestoreSnapshot( + sessionId, + sequence, + replay.GetSnapshot(), + pendingInputEcho.GetVisibleSuffix(), + null, + null); + } + + public long NextSequence(string sessionId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + + if (!_records.ContainsKey(sessionId)) + { + throw new KeyNotFoundException($"Session '{sessionId}' was not found."); + } + + return _sequenceBySession.AddOrUpdate(sessionId, 1, static (_, current) => current + 1); + } + public async Task DeleteAsync(string sessionId, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); @@ -178,6 +220,7 @@ public sealed class SessionRegistry _historyBySession.TryRemove(sessionId, out _); _replayBySession.TryRemove(sessionId, out _); _pendingInputEchoBySession.TryRemove(sessionId, out _); + _sequenceBySession.TryRemove(sessionId, out _); await _historyStore.DeleteAsync(sessionId, cancellationToken).ConfigureAwait(false); } } diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRestoreSnapshot.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRestoreSnapshot.cs new file mode 100644 index 0000000..2a9331e --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRestoreSnapshot.cs @@ -0,0 +1,9 @@ +namespace TermRemoteCtl.Agent.Sessions; + +public sealed record SessionRestoreSnapshot( + string SessionId, + long Sequence, + string ScreenText, + string PendingInput, + int? CursorRow, + int? CursorColumn); diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/PowerShellSessionHost.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/PowerShellSessionHost.cs index fd73d96..c45c9a3 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/PowerShellSessionHost.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/PowerShellSessionHost.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Runtime.Versioning; +using TermRemoteCtl.Agent.History; using TermRemoteCtl.Agent.Sessions; namespace TermRemoteCtl.Agent.Terminal; @@ -9,12 +10,17 @@ internal sealed class PowerShellSessionHost : ISessionHost, IAsyncDisposable { private readonly IConPtySessionFactory _sessionFactory; private readonly SessionRegistry _sessionRegistry; + private readonly SessionIoJournalStore _journalStore; private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal); - public PowerShellSessionHost(IConPtySessionFactory sessionFactory, SessionRegistry sessionRegistry) + public PowerShellSessionHost( + IConPtySessionFactory sessionFactory, + SessionRegistry sessionRegistry, + SessionIoJournalStore journalStore) { _sessionFactory = sessionFactory; _sessionRegistry = sessionRegistry; + _journalStore = journalStore; } public event EventHandler? OutputReceived; @@ -118,6 +124,14 @@ internal sealed class PowerShellSessionHost : ISessionHost, IAsyncDisposable private void HandleSessionOutput(object? sender, TerminalOutputEventArgs args) { _ = _sessionRegistry.AppendOutputAsync(args.SessionId, args.Chunk, CancellationToken.None); + _ = _journalStore.AppendAsync( + new SessionIoEvent( + args.SessionId, + _sessionRegistry.NextSequence(args.SessionId), + "output", + args.Chunk, + DateTimeOffset.UtcNow), + CancellationToken.None); OutputReceived?.Invoke(this, args); } } diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs index c188d30..912032f 100644 --- a/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs @@ -34,6 +34,15 @@ public sealed class TerminalWebSocketHandlerTests Assert.Equal("attached", attachedPayload!.Type); Assert.Equal(session.SessionId, attachedPayload.SessionId); + var restoreFrame = await ReceiveTextAsync(socket, CancellationToken.None); + var restorePayload = JsonSerializer.Deserialize( + restoreFrame, + new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + Assert.NotNull(restorePayload); + Assert.Equal("restore", restorePayload!.Type); + Assert.Equal(session.SessionId, restorePayload.SessionId); + fixture.TerminalHost.EmitOutput(session.SessionId, "abc"); fixture.TerminalHost.EmitOutput(session.SessionId, "def"); @@ -69,8 +78,15 @@ public sealed class TerminalWebSocketHandlerTests Assert.NotNull(attachedPayload); Assert.Equal("attached", attachedPayload!.Type); - var replayFrame = await ReceiveTextAsync(socket, CancellationToken.None); - Assert.Equal("prompt> dir\r\nnext> ", replayFrame); + var restoreFrame = await ReceiveTextAsync(socket, CancellationToken.None); + var restorePayload = JsonSerializer.Deserialize( + restoreFrame, + new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + Assert.NotNull(restorePayload); + Assert.Equal("restore", restorePayload!.Type); + Assert.Equal("prompt> dir\r\nnext> ", restorePayload.ScreenText); + Assert.Equal(string.Empty, restorePayload.PendingInput); } [Fact] @@ -95,8 +111,14 @@ public sealed class TerminalWebSocketHandlerTests Assert.NotNull(attachedPayload); Assert.Equal("attached", attachedPayload!.Type); - var replayFrame = await ReceiveTextAsync(socket, CancellationToken.None); - Assert.Equal("prompt> dir\r\n", replayFrame); + var restoreFrame = await ReceiveTextAsync(socket, CancellationToken.None); + var restorePayload = JsonSerializer.Deserialize( + restoreFrame, + new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + Assert.NotNull(restorePayload); + Assert.Equal("prompt> dir\r\n", restorePayload!.ScreenText); + Assert.Equal(string.Empty, restorePayload.PendingInput); var liveFrame = await ReceiveTextAsync(socket, CancellationToken.None); Assert.Equal("next> ", liveFrame); @@ -128,9 +150,34 @@ public sealed class TerminalWebSocketHandlerTests CancellationToken.None); _ = await ReceiveTextAsync(replaySocket, CancellationToken.None); - var replayFrame = await ReceiveTextAsync(replaySocket, CancellationToken.None); + var restoreFrame = await ReceiveTextAsync(replaySocket, CancellationToken.None); + var restorePayload = JsonSerializer.Deserialize( + restoreFrame, + new JsonSerializerOptions(JsonSerializerDefaults.Web)); - Assert.Equal("dir", replayFrame); + Assert.NotNull(restorePayload); + Assert.Equal("dir", restorePayload!.PendingInput); + Assert.Equal(string.Empty, restorePayload.ScreenText); + } + + [Fact] + public async Task Reattach_Returns_Restore_Payload_With_Pending_Input() + { + await using var fixture = new TerminalApiFixture(); + var registry = fixture.Services.GetRequiredService(); + var session = registry.Create("Shell", DateTimeOffset.UtcNow); + + registry.RecordInputEcho(session.SessionId, "dir"); + + using WebSocket socket = await fixture.Server.CreateWebSocketClient().ConnectAsync( + new Uri($"ws://localhost/ws/terminal?sessionId={session.SessionId}"), + CancellationToken.None); + + _ = await ReceiveTextAsync(socket, CancellationToken.None); + var restoreFrame = await ReceiveTextAsync(socket, CancellationToken.None); + + Assert.Contains("\"type\":\"restore\"", restoreFrame); + Assert.Contains("\"pendingInput\":\"dir\"", restoreFrame); } private static async Task ReceiveTextAsync(WebSocket socket, CancellationToken cancellationToken) @@ -313,4 +360,13 @@ public sealed class TerminalWebSocketHandlerTests } private sealed record TerminalAttachResponse(string SessionId, string Type); + + private sealed record TerminalRestoreResponse( + string SessionId, + long Sequence, + string ScreenText, + string PendingInput, + int? CursorRow, + int? CursorColumn, + string Type); } diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs index e02029a..f4a5de6 100644 --- a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs @@ -83,6 +83,27 @@ public class SessionRegistryTests Assert.Equal("prompt> dir\r\n", replay); } + [Fact] + public async Task AppendIoEventAsync_Persists_Input_And_Output_In_Order() + { + using var harness = SessionRegistryHarness.Create(); + var store = new SessionIoJournalStore(harness.DataRoot); + + await store.AppendAsync( + new SessionIoEvent("session-1", 1, "input", "dir", DateTimeOffset.UtcNow), + CancellationToken.None); + await store.AppendAsync( + new SessionIoEvent("session-1", 2, "output", "dir\r\n", DateTimeOffset.UtcNow), + CancellationToken.None); + + var lines = await File.ReadAllLinesAsync( + Path.Combine(harness.DataRoot, "sessions", "session-1.io.jsonl")); + + Assert.Equal(2, lines.Length); + Assert.Contains("\"kind\":\"input\"", lines[0]); + Assert.Contains("\"kind\":\"output\"", lines[1]); + } + [Fact] public void RecordInputEcho_Includes_Visible_User_Input_In_Replay_Snapshot() { @@ -128,6 +149,36 @@ public class SessionRegistryTests Assert.Equal("prompt> dir\r\nnext> ", replay); } + [Fact] + public void GetRestoreSnapshot_Includes_Pending_Visible_Input() + { + using var harness = SessionRegistryHarness.Create(); + var session = harness.Registry.Create("Shell", DateTimeOffset.UtcNow); + + harness.Registry.RecordInputEcho(session.SessionId, "git status"); + + var snapshot = harness.Registry.GetRestoreSnapshot(session.SessionId); + + Assert.Equal(string.Empty, snapshot.ScreenText); + Assert.Equal("git status", snapshot.PendingInput); + Assert.True(snapshot.Sequence > 0); + } + + [Fact] + public async Task GetRestoreSnapshot_Does_Not_Duplicate_Acknowledged_Input() + { + using var harness = SessionRegistryHarness.Create(); + var session = harness.Registry.Create("Shell", DateTimeOffset.UtcNow); + + harness.Registry.RecordInputEcho(session.SessionId, "dir\r"); + await harness.Registry.AppendOutputAsync(session.SessionId, "PS> dir\r\nnext> ", CancellationToken.None); + + var snapshot = harness.Registry.GetRestoreSnapshot(session.SessionId); + + Assert.Equal("PS> dir\r\nnext> ", snapshot.ScreenText); + Assert.Equal(string.Empty, snapshot.PendingInput); + } + [Fact] public async Task Delete_Removes_Session_Record_And_History_Log() { diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/ConPtySessionFactoryTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/ConPtySessionFactoryTests.cs index 11a616f..df44563 100644 --- a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/ConPtySessionFactoryTests.cs +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/ConPtySessionFactoryTests.cs @@ -77,7 +77,10 @@ public class ConPtySessionFactoryTests RingBufferLineLimit = 4000, }); var registry = new SessionRegistry(new SessionHistoryStore(dataRoot), options); - var host = new PowerShellSessionHost(new ConPtySessionFactory(), registry); + var host = new PowerShellSessionHost( + new ConPtySessionFactory(), + registry, + new SessionIoJournalStore(dataRoot)); return new HostHarness(dataRoot, registry, host); } diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/PowerShellSessionHostTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/PowerShellSessionHostTests.cs index a9e7ab4..d5bd4a8 100644 --- a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/PowerShellSessionHostTests.cs +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/PowerShellSessionHostTests.cs @@ -101,7 +101,7 @@ public class PowerShellSessionHostTests RingBufferLineLimit = lineLimit, }); var registry = new SessionRegistry(new SessionHistoryStore(dataRoot), options); - var host = new PowerShellSessionHost(factory, registry); + var host = new PowerShellSessionHost(factory, registry, new SessionIoJournalStore(dataRoot)); return new HostHarness(dataRoot, registry, host); } diff --git a/docs/superpowers/plans/2026-04-06-terminal-reconnect-recovery.md b/docs/superpowers/plans/2026-04-06-terminal-reconnect-recovery.md new file mode 100644 index 0000000..49b801f --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-terminal-reconnect-recovery.md @@ -0,0 +1,536 @@ +# Terminal Reconnect Recovery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add backend-owned reconnect restore state and a raw terminal I/O journal so ordinary shell sessions reconnect with visible input and output intact. + +**Architecture:** Keep the existing helper-backed ConPTY runtime and websocket transport, but separate session runtime, raw journal, and restore state. Move reconnect truth to the backend by emitting an explicit `restore` payload during websocket attach, while the Flutter client applies that payload as authoritative terminal state instead of inferring from output replay alone. + +**Tech Stack:** ASP.NET Core, WebSocket, helper-backed ConPTY, Flutter, Riverpod, xterm, JSON lines storage, xUnit, Flutter widget tests. + +--- + +### Task 1: Add Raw Terminal Journal Models And Storage + +**Files:** +- Create: `apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionIoEvent.cs` +- Create: `apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionIoJournalStore.cs` +- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptions.cs` +- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs` +- Test: `apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs` + +- [ ] **Step 1: Write the failing journal storage tests** + +```csharp +[Fact] +public async Task AppendIoEventAsync_Persists_Input_And_Output_In_Order() +{ + using var harness = SessionRegistryHarness.Create(); + var store = new SessionIoJournalStore(harness.DataRoot); + + await store.AppendAsync(new SessionIoEvent("session-1", 1, "input", "dir", DateTimeOffset.UtcNow), CancellationToken.None); + await store.AppendAsync(new SessionIoEvent("session-1", 2, "output", "dir\r\n", DateTimeOffset.UtcNow), CancellationToken.None); + + var lines = await File.ReadAllLinesAsync(Path.Combine(harness.DataRoot, "sessions", "session-1.io.jsonl")); + Assert.Equal(2, lines.Length); + Assert.Contains("\"kind\":\"input\"", lines[0]); + Assert.Contains("\"kind\":\"output\"", lines[1]); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/TermRemoteCtl.Agent.Tests.csproj --filter AppendIoEventAsync_Persists_Input_And_Output_In_Order` +Expected: FAIL with missing `SessionIoJournalStore` or `SessionIoEvent` + +- [ ] **Step 3: Add the journal event record** + +```csharp +namespace TermRemoteCtl.Agent.History; + +public sealed record SessionIoEvent( + string SessionId, + long Sequence, + string Kind, + string Payload, + DateTimeOffset TimestampUtc); +``` + +- [ ] **Step 4: Add the journal file store** + +```csharp +public sealed class SessionIoJournalStore +{ + private readonly string _sessionRoot; + + public SessionIoJournalStore(string rootPath) + { + _sessionRoot = Path.Combine(rootPath, "sessions"); + Directory.CreateDirectory(_sessionRoot); + } + + public async Task AppendAsync(SessionIoEvent ioEvent, CancellationToken cancellationToken) + { + var filePath = Path.Combine(_sessionRoot, $"{ioEvent.SessionId}.io.jsonl"); + var line = JsonSerializer.Serialize(ioEvent) + Environment.NewLine; + await File.AppendAllTextAsync(filePath, line, new UTF8Encoding(false), cancellationToken).ConfigureAwait(false); + } +} +``` + +- [ ] **Step 5: Add configuration and service registration** + +```csharp +public sealed class AgentOptions +{ + public string DataRoot { get; set; } = string.Empty; + public int RingBufferLineLimit { get; set; } = 4000; + public int SessionJournalRetentionDays { get; set; } = 7; +} +``` + +```csharp +builder.Services.AddSingleton(sp => +{ + var options = sp.GetRequiredService>().Value; + return new SessionIoJournalStore(options.DataRoot); +}); +``` + +- [ ] **Step 6: Run the backend unit test to verify it passes** + +Run: `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/TermRemoteCtl.Agent.Tests.csproj --filter SessionRegistryTests` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionIoEvent.cs apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionIoJournalStore.cs apps/windows_agent/src/TermRemoteCtl.Agent/Configuration/AgentOptions.cs apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs +git commit -m "feat: add terminal session io journal" +``` + +### Task 2: Extend Session Registry With Restore Snapshot State + +**Files:** +- Create: `apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRestoreSnapshot.cs` +- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs` +- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/History/PendingInputEchoTracker.cs` +- Test: `apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs` + +- [ ] **Step 1: Write failing restore snapshot tests** + +```csharp +[Fact] +public void GetRestoreSnapshot_Includes_Pending_Visible_Input() +{ + using var harness = SessionRegistryHarness.Create(); + var session = harness.Registry.Create("Shell", DateTimeOffset.UtcNow); + + harness.Registry.RecordInputEcho(session.SessionId, "git status"); + + var snapshot = harness.Registry.GetRestoreSnapshot(session.SessionId); + + Assert.Equal(string.Empty, snapshot.ScreenText); + Assert.Equal("git status", snapshot.PendingInput); + Assert.True(snapshot.Sequence > 0); +} +``` + +```csharp +[Fact] +public async Task GetRestoreSnapshot_Does_Not_Duplicate_Acknowledged_Input() +{ + using var harness = SessionRegistryHarness.Create(); + var session = harness.Registry.Create("Shell", DateTimeOffset.UtcNow); + + harness.Registry.RecordInputEcho(session.SessionId, "dir\r"); + await harness.Registry.AppendOutputAsync(session.SessionId, "PS> dir\r\nnext> ", CancellationToken.None); + + var snapshot = harness.Registry.GetRestoreSnapshot(session.SessionId); + + Assert.Equal("PS> dir\r\nnext> ", snapshot.ScreenText); + Assert.Equal(string.Empty, snapshot.PendingInput); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/TermRemoteCtl.Agent.Tests.csproj --filter "GetRestoreSnapshot_"` +Expected: FAIL with missing `GetRestoreSnapshot` + +- [ ] **Step 3: Add the restore snapshot model** + +```csharp +namespace TermRemoteCtl.Agent.Sessions; + +public sealed record SessionRestoreSnapshot( + string SessionId, + long Sequence, + string ScreenText, + string PendingInput, + int? CursorRow, + int? CursorColumn); +``` + +- [ ] **Step 4: Extend `SessionRegistry` to track restore sequence** + +```csharp +private readonly ConcurrentDictionary _sequenceBySession = new(); + +public long NextSequence(string sessionId) +{ + return _sequenceBySession.AddOrUpdate(sessionId, 1, (_, current) => current + 1); +} +``` + +- [ ] **Step 5: Add restore snapshot retrieval** + +```csharp +public SessionRestoreSnapshot GetRestoreSnapshot(string sessionId) +{ + var replay = _replayBySession.GetOrAdd(sessionId, _ => new TerminalReplayBuffer(ReplayCharacterLimit)); + var pending = _pendingInputEchoBySession.GetOrAdd(sessionId, _ => new PendingInputEchoTracker()); + var sequence = _sequenceBySession.GetOrAdd(sessionId, 1); + + return new SessionRestoreSnapshot( + sessionId, + sequence, + replay.GetSnapshot(), + pending.GetVisibleSuffix(), + null, + null); +} +``` + +- [ ] **Step 6: Update input/output mutation points** + +```csharp +public void RecordInputEcho(string sessionId, string input) +{ + var tracker = _pendingInputEchoBySession.GetOrAdd(sessionId, _ => new PendingInputEchoTracker()); + tracker.Record(input); + NextSequence(sessionId); +} +``` + +```csharp +public async Task AppendOutputAsync(string sessionId, string chunk, CancellationToken cancellationToken) +{ + // existing history and replay mutations + _pendingInputEchoBySession.GetOrAdd(sessionId, _ => new PendingInputEchoTracker()).ObserveOutput(chunk); + NextSequence(sessionId); + await _historyStore.AppendAsync(sessionId, chunk, cancellationToken).ConfigureAwait(false); +} +``` + +- [ ] **Step 7: Run the backend unit tests to verify they pass** + +Run: `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/TermRemoteCtl.Agent.Tests.csproj --filter SessionRegistryTests` +Expected: PASS + +- [ ] **Step 8: Commit** + +```bash +git add apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRestoreSnapshot.cs apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs apps/windows_agent/src/TermRemoteCtl.Agent/History/PendingInputEchoTracker.cs apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs +git commit -m "feat: add session restore snapshot state" +``` + +### Task 3: Emit Restore Payload And Journal Events On Websocket Attach + +**Files:** +- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs` +- Modify: `apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/PowerShellSessionHost.cs` +- Test: `apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs` + +- [ ] **Step 1: Write failing websocket restore tests** + +```csharp +[Fact] +public async Task Reattach_Returns_Restore_Payload_With_Pending_Input() +{ + await using var fixture = new TerminalApiFixture(); + var registry = fixture.Services.GetRequiredService(); + var session = registry.Create("Shell", DateTimeOffset.UtcNow); + + registry.RecordInputEcho(session.SessionId, "dir"); + + using WebSocket socket = await fixture.Server.CreateWebSocketClient().ConnectAsync( + new Uri($"ws://localhost/ws/terminal?sessionId={session.SessionId}"), + CancellationToken.None); + + _ = await ReceiveTextAsync(socket, CancellationToken.None); + var restoreFrame = await ReceiveTextAsync(socket, CancellationToken.None); + + Assert.Contains("\"type\":\"restore\"", restoreFrame); + Assert.Contains("\"pendingInput\":\"dir\"", restoreFrame); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/TermRemoteCtl.Agent.IntegrationTests.csproj --filter Reattach_Returns_Restore_Payload_With_Pending_Input` +Expected: FAIL because only replay text is sent + +- [ ] **Step 3: Add the restore response contract** + +```csharp +private sealed record TerminalRestoreResponse( + string SessionId, + long Sequence, + string ScreenText, + string PendingInput, + int? CursorRow, + int? CursorColumn, + string Type = "restore"); +``` + +- [ ] **Step 4: Send restore payload after attach acknowledgement** + +```csharp +var restore = registry.GetRestoreSnapshot(sessionId); +await SendJsonAsync(socket, new TerminalAttachResponse(sessionId), sendGate, context.RequestAborted).ConfigureAwait(false); +await SendJsonAsync( + socket, + new TerminalRestoreResponse( + restore.SessionId, + restore.Sequence, + restore.ScreenText, + restore.PendingInput, + restore.CursorRow, + restore.CursorColumn), + sendGate, + context.RequestAborted).ConfigureAwait(false); +``` + +- [ ] **Step 5: Journal websocket lifecycle and PTY traffic** + +```csharp +await journalStore.AppendAsync(new SessionIoEvent(sessionId, registry.NextSequence(sessionId), "attach", string.Empty, DateTimeOffset.UtcNow), context.RequestAborted); +``` + +```csharp +registry.RecordInputEcho(sessionId, message.Input); +await journalStore.AppendAsync(new SessionIoEvent(sessionId, registry.NextSequence(sessionId), "input", message.Input, DateTimeOffset.UtcNow), cancellationToken); +await host.WriteInputAsync(sessionId, message.Input, cancellationToken).ConfigureAwait(false); +``` + +```csharp +_ = _sessionRegistry.AppendOutputAsync(args.SessionId, args.Chunk, CancellationToken.None); +_ = _journalStore.AppendAsync(new SessionIoEvent(args.SessionId, _sessionRegistry.NextSequence(args.SessionId), "output", args.Chunk, DateTimeOffset.UtcNow), CancellationToken.None); +``` + +- [ ] **Step 6: Run integration tests to verify they pass** + +Run: `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/TermRemoteCtl.Agent.IntegrationTests.csproj --filter TerminalWebSocketHandlerTests` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/PowerShellSessionHost.cs apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/Realtime/TerminalWebSocketHandlerTests.cs +git commit -m "feat: send terminal restore payload on attach" +``` + +### Task 4: Teach Flutter To Restore From Backend Snapshot + +**Files:** +- Create: `apps/mobile_app/lib/features/terminal/terminal_restore_payload.dart` +- Modify: `apps/mobile_app/lib/features/terminal/terminal_socket_session.dart` +- Modify: `apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart` +- Modify: `apps/mobile_app/lib/features/terminal/terminal_page.dart` +- Test: `apps/mobile_app/test/features/terminal/terminal_socket_session_test.dart` +- Test: `apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart` +- Test: `apps/mobile_app/test/widget_test.dart` + +- [ ] **Step 1: Write failing Flutter restore tests** + +```dart +testWidgets('terminal reconnect applies restore payload before live frames', (tester) async { + final transportFactory = _QueuedTerminalSocketTransportFactory( + connectionStartupFrames: [ + const [ + _StartupFrame('{"type":"attached","sessionId":"session-1"}'), + _StartupFrame('{"type":"restore","sessionId":"session-1","sequence":4,"screenText":"PS> gi","pendingInput":"t status"}'), + ], + ], + ); + + await _pumpTerminalPage( + tester, + session: _session('session-1', 'codex-main'), + socketFactory: TerminalSocketSessionFactory(transportFactory: transportFactory.create), + ); + + final terminal = tester.widget(find.byType(TerminalView)).terminal; + expect(terminal.buffer.getText(), contains('PS> git status')); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `C:\tools\flutter\bin\flutter.bat test test/features/terminal/terminal_socket_session_test.dart test/widget_test.dart` +Expected: FAIL because restore frames are not parsed separately + +- [ ] **Step 3: Add restore payload model** + +```dart +class TerminalRestorePayload { + const TerminalRestorePayload({ + required this.sessionId, + required this.sequence, + required this.screenText, + required this.pendingInput, + }); + + factory TerminalRestorePayload.fromJson(Map json) { + return TerminalRestorePayload( + sessionId: json['sessionId'] as String, + sequence: json['sequence'] as int, + screenText: (json['screenText'] as String?) ?? '', + pendingInput: (json['pendingInput'] as String?) ?? '', + ); + } +} +``` + +- [ ] **Step 4: Parse restore frames separately from live output** + +```dart +Future connect({ + required void Function(String frame) onFrame, + required void Function(TerminalRestorePayload restore) onRestore, + void Function()? onDisconnected, +}) +``` + +```dart +if (decoded is Map && decoded['type'] == 'restore') { + onRestore(TerminalRestorePayload.fromJson(Map.from(decoded))); + return; +} +``` + +- [ ] **Step 5: Apply restore payload as authoritative state in `TerminalPage`** + +```dart +void _handleRestorePayload(TerminalRestorePayload restore) { + _resetTerminalForReplay(); + final combined = restore.screenText + restore.pendingInput; + if (combined.isNotEmpty) { + terminal.write(combined); + } +} +``` + +- [ ] **Step 6: Run Flutter tests to verify they pass** + +Run: `C:\tools\flutter\bin\flutter.bat test test/features/terminal/terminal_page_input_test.dart test/features/terminal/terminal_socket_session_test.dart test/features/terminal/terminal_session_coordinator_test.dart test/widget_test.dart` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add apps/mobile_app/lib/features/terminal/terminal_restore_payload.dart apps/mobile_app/lib/features/terminal/terminal_socket_session.dart apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart apps/mobile_app/lib/features/terminal/terminal_page.dart apps/mobile_app/test/features/terminal/terminal_socket_session_test.dart apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart apps/mobile_app/test/widget_test.dart +git commit -m "feat: restore terminal state from backend snapshot" +``` + +### Task 5: Make Restore Snapshot The Primary Reconnect Path + +**Files:** +- Modify: `apps/mobile_app/lib/features/terminal/terminal_page.dart` +- Modify: `apps/mobile_app/test/widget_test.dart` +- Modify: `docs/testing/manual-smoke-checklist.md` + +- [ ] **Step 1: Write failing test that proves reconnect no longer depends on output-only replay** + +```dart +testWidgets('terminal reconnect restores pending input without history seed fallback', (tester) async { + final transportFactory = _QueuedTerminalSocketTransportFactory( + connectionStartupFrames: [ + const [ + _StartupFrame('{"type":"attached","sessionId":"session-1"}'), + _StartupFrame('{"type":"restore","sessionId":"session-1","sequence":7,"screenText":"PS> gi","pendingInput":"t status"}'), + ], + ], + ); + + await _pumpTerminalPage( + tester, + session: _session('session-1', 'codex-main'), + apiClient: _FakeAgentApiClient(lines: const []), + socketFactory: TerminalSocketSessionFactory(transportFactory: transportFactory.create), + ); + + final terminal = tester.widget(find.byType(TerminalView)).terminal; + expect(terminal.buffer.getText(), contains('PS> git status')); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `C:\tools\flutter\bin\flutter.bat test test/widget_test.dart` +Expected: FAIL until restore payload becomes the primary reconnect source + +- [ ] **Step 3: Simplify reconnect restore logic** + +```dart +if (connectionState == TerminalConnectionState.reconnecting) { + _resetTerminalForReplay(); + _historySeeded = false; + _receivedSocketFrame = false; +} +``` + +```dart +if (_receivedRestorePayload) { + _cancelHistorySeedTimer(); + return; +} +``` + +- [ ] **Step 4: Update the manual smoke checklist** + +```markdown +10. Type a partial command, background the app, reopen it, and confirm the typed command is still visible. +11. Execute a command, reconnect during output, and confirm the command is not duplicated after restore. +``` + +- [ ] **Step 5: Run final verification** + +Run: `C:\tools\flutter\bin\flutter.bat test test/widget_test.dart test/features/terminal/terminal_page_input_test.dart test/features/terminal/terminal_socket_session_test.dart test/features/terminal/terminal_session_coordinator_test.dart` +Expected: PASS + +Run: `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/TermRemoteCtl.Agent.Tests.csproj` +Expected: PASS + +Run: `dotnet test apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/TermRemoteCtl.Agent.IntegrationTests.csproj --filter "TerminalWebSocketHandlerTests|SessionHistoryApiTests"` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add apps/mobile_app/lib/features/terminal/terminal_page.dart apps/mobile_app/test/widget_test.dart docs/testing/manual-smoke-checklist.md +git commit -m "refactor: make terminal restore snapshot authoritative" +``` + +## Self-Review + +- Spec coverage: + - raw journal is covered by Tasks 1 and 3 + - restore snapshot state is covered by Task 2 + - websocket attach restore protocol is covered by Task 3 + - Flutter restore consumption is covered by Tasks 4 and 5 +- Placeholder scan: + - each task contains concrete files, code, commands, and expected results +- Type consistency: + - `SessionIoEvent`, `SessionRestoreSnapshot`, `TerminalRestorePayload`, `GetRestoreSnapshot`, and `RecordInputEcho` are used consistently across the plan + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-04-06-terminal-reconnect-recovery.md`. Two execution options: + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints + +**Which approach?** diff --git a/docs/superpowers/specs/2026-04-06-terminal-reconnect-recovery-design.md b/docs/superpowers/specs/2026-04-06-terminal-reconnect-recovery-design.md new file mode 100644 index 0000000..a43eeac --- /dev/null +++ b/docs/superpowers/specs/2026-04-06-terminal-reconnect-recovery-design.md @@ -0,0 +1,347 @@ +# Terminal Reconnect Recovery Design + +## Goal + +Upgrade terminal reconnect handling from "recent output replay" to "session survives and the user can recover what they were seeing", while also introducing a raw terminal I/O journal for diagnostics and audit. + +## Product Context + +- Audience: mobile users connecting to a Windows terminal agent over unstable local, Wi-Fi, or mobile networks +- Primary pain point: after iOS backgrounding or transient disconnects, the terminal often reconnects to a shell that is still alive, but the restored screen is incomplete +- Current business impact: + - users cannot trust that the terminal after reconnect reflects the last visible state + - users lose confidence when typed commands disappear while output remains + - support and debugging are harder because the system stores output-oriented history but not a full terminal event record + +## Current State + +### What Works Today + +- The Windows agent keeps the ConPTY-backed session alive across client disconnects. +- The mobile app reconnects automatically and re-attaches the websocket session. +- The backend stores: + - line-oriented history for scrollback APIs + - a replay buffer of recent output text for websocket attach replay +- The mobile app suppresses obvious duplicate replay on reconnect and preserves ordinary shell output better than before. + +### What Is Structurally Missing + +- The restore model is still output-centric. +- The backend does not maintain an authoritative renderable screen snapshot. +- The restore path cannot fully represent: + - pending user input that has not yet been echoed back + - cursor position and terminal state beyond what raw output text implies + - richer VT state such as alternate screen, screen clears, or in-line editing +- There is no raw I/O journal that can answer "what exactly was sent and received". + +### Consequence + +The product currently preserves session runtime better than it preserves session experience. The shell is usually still alive, but the user-visible screen after reconnect is only an approximation. + +## Scope + +### In Scope + +- Reconnect recovery for ordinary shell-oriented terminal use +- Raw terminal I/O journal for input, output, resize, attach, and detach events +- Backend-owned restore state exposed explicitly to clients +- Mobile terminal restore flow updated to consume restore payloads instead of guessing from output replay +- Reliable recovery for: + - prompt + partially typed command + - typed command that has not fully echoed yet + - ordinary command output and prompt progression + +### Out Of Scope + +- Full fidelity recovery for all fullscreen and curses-based TUIs such as `vim`, `less`, `top`, or `htop` +- Cross-device collaborative session editing +- Server-side playback UI for historical terminal sessions +- Security and retention policy redesign beyond what the journal feature minimally needs + +## Problem Statement + +The system currently treats reconnect recovery as a transport problem. Professionally, it is a state recovery problem. + +There are three different truths in a terminal system: + +1. The runtime truth: the PTY process is still alive +2. The audit truth: what bytes and semantic events went in and out +3. The UX truth: what the user last saw on screen + +Today, TermRemoteCtl handles runtime truth reasonably well, has partial output logging, and approximates UX truth by replaying recent output. The design must separate these concerns instead of overloading output replay to serve every use case. + +## Design Principles + +- The backend owns terminal truth. The client should stop reconstructing terminal state from ambiguous fragments when the backend can provide a better restore payload. +- Preserve session continuity first, then preserve screen continuity. +- Store audit data and restore data separately because they serve different business needs. +- Keep the first implementation focused on shell workflows. Do not claim full VT recovery until the system actually maintains full terminal state. +- Prefer explicit protocol payloads over frontend heuristics. + +## Approach Options + +### Option A: Keep Output Replay and Add More Client Heuristics + +- Pros: smallest short-term change +- Cons: compounds existing fragility, keeps recovery logic split across backend and Flutter UI, still does not provide audit completeness + +### Option B: Add Raw Journal and Backend-Owned Restore Snapshot + +- Pros: best balance of correctness, implementation size, and future extensibility +- Cons: requires protocol changes and new backend state management + +### Option C: Introduce a Full Headless Terminal Emulator on the Agent Immediately + +- Pros: strongest long-term terminal fidelity +- Cons: largest implementation cost, more operational and compatibility risk, too large for the immediate business pain + +## Recommended Approach + +Choose Option B. + +This addresses the actual user complaint with the smallest architecture change that still moves the system in a professional direction. It creates the right boundaries: + +- PTY runtime remains the source of process continuity +- raw journal becomes the source of audit truth +- restore snapshot becomes the source of reconnect UX truth + +It also leaves room for a future Option C if the product later needs true fullscreen TUI restoration. + +## Target Architecture + +### Runtime Layer + +Keep the existing ConPTY-backed session host. It remains responsible for starting shells, forwarding input, reading output, and handling resize. + +### Journal Layer + +Add a raw session journal that records semantic terminal events with timestamps and sequence numbers. + +Event kinds: + +- `attach` +- `detach` +- `input` +- `output` +- `resize` + +Each journal entry should include: + +- session id +- monotonic sequence number +- UTC timestamp +- event kind +- payload + +The journal exists for observability, debugging, and future playback. It is not the primary reconnect payload. + +### Restore Layer + +Add a backend restore state object that is cheap to update and explicit about what the client should restore. + +The initial restore state should include: + +- recent renderable output snapshot +- pending visible input suffix that has not yet been authoritatively echoed +- cursor metadata when available +- sequence number of the snapshot + +The restore state should be generated entirely on the backend and sent to clients during attach. + +## Restore Model + +### Phase 1 Restore Fidelity + +The first professional-grade target is not "full VT screen emulator". It is: + +- shell prompt continuity +- visible command continuity +- ordinary command output continuity +- reconnect without losing the user's typed-but-not-yet-echoed command + +This is enough to solve the main business complaint and avoid overengineering. + +### Restore Data Components + +#### 1. Recent Renderable Output Snapshot + +This remains output-derived, but it is formalized as part of a restore payload instead of being an implicit replay side effect. + +#### 2. Pending Visible Input + +This tracks user input that should still be visible after reconnect because the shell has not fully echoed or absorbed it yet. + +Examples: + +- User typed `git status` but the reconnect happened before the line was echoed +- User typed part of a command and the connection dropped before Enter + +This data should be: + +- updated when client input is received +- reduced or cleared when backend output confirms the echo +- appended to the restore payload after the renderable output snapshot + +#### 3. Snapshot Sequence + +The restore payload should carry a sequence number so the client can reason about whether live frames arrived before or after the restore payload and suppress stale duplication safely. + +## Protocol Changes + +### Existing Attach Flow + +Today the websocket attach path sends: + +- `attached` +- replay text, if present +- live text frames + +### New Attach Flow + +The websocket attach path should send: + +- `attached` +- `restore` JSON payload +- optional live delta frames after the snapshot sequence + +Proposed restore payload shape: + +```json +{ + "type": "restore", + "sessionId": "session-123", + "sequence": 1042, + "screenText": "PS C:\\repo> gi", + "pendingInput": "t status", + "cursor": { + "column": 18, + "row": 0 + } +} +``` + +For the first implementation, `cursor` can be optional. The key business requirement is that `screenText + pendingInput` reconstructs what the user expects to see for normal shell usage. + +## Client Behavior + +The Flutter client should stop treating reconnect restore as "history seed plus attach replay text plus duplicate suppression guesses". + +Instead: + +- reset the terminal buffer on reconnect +- apply the backend `restore` payload as the first authoritative state +- then process live frames that are newer than the restore sequence +- keep client-side suppression only as a narrow safety net, not as the primary correctness strategy + +This simplifies the frontend and moves recovery correctness to the component that actually owns the session. + +## Raw Journal Design + +### Why Journal Separately + +The journal answers different questions than restore state: + +- Restore state answers: "What should the user see right now?" +- Journal answers: "What happened in this session over time?" + +If these remain separate: + +- reconnect UX can stay fast and compact +- diagnostics stay trustworthy +- future features like export, playback, or audit retention remain possible + +### Storage Requirements + +- append-only per-session file storage is sufficient for the first version +- use UTF-8 JSON lines +- store compact payloads and escaped control characters explicitly +- retention should be configurable + +## Sequence And Ack Model + +The system should become explicit about input confirmation. + +Business rule: + +- Input is considered pending until restore state logic sees output that makes the input visibly present on screen. + +This is not a full mosh-style predictive protocol. It is a practical echo-ack model suitable for shell workflows. + +Benefits: + +- typed commands no longer vanish during reconnect +- echoed commands do not get duplicated after reconnect + +## Testing Strategy + +### Backend Unit Tests + +Add tests for: + +- pending visible input is added to restore state +- echoed output clears matching pending input +- split output chunks still clear pending input correctly +- journal records input, output, resize, attach, and detach entries in order + +### Backend Integration Tests + +Add websocket attach tests for: + +- reconnect after input but before echo returns visible input in restore payload +- reconnect after echo does not duplicate the command +- restore payload and subsequent live frames preserve ordering by sequence + +### Flutter Tests + +Add tests for: + +- terminal page restores from `restore` payload rather than text replay only +- reconnect clears stale terminal content and applies authoritative restore state +- visible command text survives reconnect for ordinary shell scenarios + +### Manual Product Tests + +Validate on iOS first: + +- type without pressing Enter, background app, resume, confirm input is still visible +- press Enter, background during prompt/output transition, resume, confirm no duplicate command text +- run several ordinary PowerShell commands and verify prompt continuity + +## Rollout Plan + +### Phase 1 + +- Add raw journal +- Add restore payload with renderable output snapshot and pending input +- Update mobile client to consume restore payload +- Keep existing replay text path behind a compatibility fallback during rollout + +### Phase 2 + +- Remove fallback replay heuristics once restore payload is proven stable +- Expose journal inspection tooling for diagnostics if needed + +### Phase 3 + +- Evaluate whether the product needs full terminal state emulation for fullscreen TUI recovery + +## Risks + +- A partial restore model could still mishandle advanced VT interaction and create false confidence +- Journal retention could grow storage unexpectedly if left unbounded +- Client/server sequence handling could introduce duplicate or missing frames if not carefully tested + +## Mitigations + +- Explicitly define Phase 1 as shell-oriented, not full TUI recovery +- Keep journal retention configurable and bounded +- Make restore sequence ordering part of integration tests before rollout +- Keep restore payload explicit and versionable + +## Acceptance Criteria + +- A reconnect after ordinary shell input preserves what the user expects to see, including pending typed commands +- A reconnect after command echo does not duplicate visible command text +- The backend stores raw input and output events separately from restore state +- The mobile client restores terminal state from backend-owned restore payloads instead of depending primarily on replay heuristics +- The architecture is ready for later upgrade to full screen-state emulation without rewriting the journal layer diff --git a/docs/testing/manual-smoke-checklist.md b/docs/testing/manual-smoke-checklist.md index c1bb1ce..337fbcb 100644 --- a/docs/testing/manual-smoke-checklist.md +++ b/docs/testing/manual-smoke-checklist.md @@ -9,3 +9,5 @@ 7. Scroll upward and confirm older history loads. 8. Trigger one preset command and confirm it appears in the terminal. 9. Terminate one session and confirm only that session exits. +10. Type a partial command, background the app, reopen it, and confirm the typed command is still visible. +11. Execute a command, reconnect during output, and confirm the command is not duplicated after restore.