Skip to content

feat: Remote Instance Monitoring and Control#157

Merged
subsy merged 23 commits intomainfrom
feat/remotes
Jan 19, 2026
Merged

feat: Remote Instance Monitoring and Control#157
subsy merged 23 commits intomainfrom
feat/remotes

Conversation

@subsy
Copy link
Owner

@subsy subsy commented Jan 19, 2026

Summary

This PR adds comprehensive remote instance monitoring and control capabilities to ralph-tui, allowing users to connect to and monitor ralph-tui instances running on other machines.

Features

  • Remote Listener (ralph-tui listen): Start a WebSocket server to accept remote connections
  • Remote Connection (ralph-tui remote add/remove/list): Configure remote instances
  • Tab-Based Navigation: Switch between local and remote instances with number keys
  • Full Remote Control: Pause, resume, interrupt remote engines; view tasks, iterations, subagent trees
  • Connection Resilience: Auto-reconnect with exponential backoff, connection status indicators
  • Secure Token Management: Server tokens (90-day), connection tokens (24-hour auto-refresh)
  • Config Push: Push configuration to remote instances (ralph-tui remote push-config)

Bug Fixes

  • Fix subagent tree not displaying for remote instances
  • Fix keyboard navigation (j/k) for remote subagent tree selection

Documentation

  • Add documentation for listen, remote, doctor, info commands
  • Fix token format and audit log examples in docs
  • Update sidebar navigation to include new CLI commands

Test plan

  • Start remote listener on one machine: ralph-tui listen
  • Add remote from another machine: ralph-tui remote add <alias> <host>:<port>
  • Connect and view remote instance with Tab navigation
  • Verify subagent tree displays and keyboard navigation works (j/k)
  • Test pause/resume/interrupt commands on remote
  • Test config push: ralph-tui remote push-config <alias>

Summary by CodeRabbit

  • New Features

    • Remote Instance Management: start a remote listener (run --listen, --listen-port, --rotate-token), add/list/remove/test remotes, push configs, and token-based secure connections.
    • Multi-instance UI: tabs for local + remotes, connection status indicators, toasts for connection events, and remote instance controls.
  • Documentation

    • Updated docs and README with remote setup, security model, CLI examples and workflows.
  • Tests

    • Extensive test coverage for remote, token and config workflows.

✏️ Tip: You can customize this high-level summary in your review settings.

subsy added 12 commits January 19, 2026 01:07
Implement WebSocket server for remote ralph-tui control with:
- `ralph-tui listen` command starts server on port 7890 (configurable)
- Secure token authentication generated on first run
- Token rotation via --rotate-token flag
- Daemon mode with --daemon flag for background operation
- Binds to localhost-only without token, all interfaces with token
- Comprehensive audit logging of all remote actions
Implement remote server configuration management with CLI commands:
- `ralph-tui remote add <alias> <host:port> --token <token>` adds a remote
- `ralph-tui remote list` shows configured remotes with connection status
- `ralph-tui remote remove <alias>` removes a remote
- `ralph-tui remote test <alias>` tests connectivity

Configuration stored in TOML format at ~/.config/ralph-tui/remotes.toml
Add tab bar for navigating between local and remote ralph-tui instances.

- Tab bar shows at top of TUI with connection status indicators
- First tab is always "Local" (current machine)
- Remote tabs show alias with status (●/◐/○ for connected/connecting/disconnected)
- Keyboard navigation: 1-9 for direct tab access, [ and ] to cycle
- Selecting a disconnected tab triggers reconnection (no auto-reconnect on startup)
- Subtle "+" affordance in tab bar for adding new remotes
Implements full remote control capabilities for ralph-tui remote instances:

- Added message types for subscriptions, queries, and operations
  (Subscribe, GetState, GetTasks, Pause, Resume, Interrupt, etc.)
- Extended RemoteClient with request/response correlation system
  and typed methods for all remote operations
- Added engine integration to server with subscription system
- Server broadcasts engine events to subscribed clients
- All local TUI features now accessible via remote protocol

Acceptance Criteria:
✓ View agents, logs, progress (via state queries and subscriptions)
✓ Pause, resume, cancel tasks (via operation messages)
✓ Start new tasks (via continue operation)
✓ Modify queues (via add/remove iterations)
✓ Access all local TUI features (full parity via message protocol)
✓ Log streaming at full fidelity (engine events forwarded to clients)
Implement auto-reconnect with exponential backoff for remote connections:

- Add 'reconnecting' connection status with visual indicator (⟳)
- Implement exponential backoff (1s→30s max, 10 retries default)
- Track connection metrics: latency (ping/pong RTT) and duration
- Show metrics in TabBar for selected connected remotes
- Add Toast component for reconnection notifications
- Silent retries up to threshold (3), then alert user
- Toast types: reconnecting, reconnected, reconnect_failed, error
Implement two-tier token system for remote authentication:

- Server token (90 days): stored in config, used for initial auth, shown only once
- Connection token (24 hours): issued on auth, auto-refreshed while connected

Key changes:
- types.ts: Add ServerToken, ConnectionToken, TOKEN_LIFETIMES, TokenRefresh messages
- token.ts: Full refactor with two-tier tokens, in-memory connection token store
- server.ts: Issue connection tokens on auth, handle refresh requests, periodic cleanup
- client.ts: Store connection token, auto-refresh 1 hour before expiration
- listen.ts: Enhanced token display with expiration info, security warnings
- audit.ts: Add logAction helper, extend logAuth with details

Includes automatic migration from legacy single-token config format.
- Add push-config protocol messages (check_config, push_config)
- Implement server handlers with TOML validation and backup creation
- Add client methods for config operations
- Add CLI push-config subcommand with --preview, --force, --all, --scope
- Add comprehensive test suite for all remoting functionality (168 tests)
- Add full documentation for listen and remote commands
- Fix token format in examples to match actual base64url output
  (code uses randomBytes(32).toString('base64url'), not rtui_ prefix)
- Fix audit log action names (connection_connect, not connect)
- Fix clientId format in audit log examples
- Update navigation.ts to include remote, listen, doctor, info in CLI Commands section
- Create doctor.mdx documentation page
- Create info.mdx documentation page
- Add subagentTree field to RemoteEngineState type
- Include subagent tree data in server's get_state response
- Add remoteSubagentTree state in RunApp for remote viewing
- Use appropriate tree based on isViewingRemote in SubagentTreePanel
- Update navigateSubagentTree to use remoteSubagentTree when viewing remote
- Use displayCurrentTaskId instead of local currentTaskId for root node
- Handle remote subagent output display gracefully (show basic info from tree)
- Fix isViewingCurrentTask check to use appropriate task ID for remote
@vercel
Copy link

vercel bot commented Jan 19, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Review Updated (UTC)
ralph-tui Ignored Ignored Preview Jan 19, 2026 11:44pm

Request Review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 19, 2026

Warning

Rate limit exceeded

@subsy has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 6 minutes and 57 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between 8562e5e and afb2806.

📒 Files selected for processing (2)
  • README.md
  • src/tui/components/RunApp.tsx

Walkthrough

This PR adds a Remote Instance Management subsystem: WebSocket server/client, two-tier token auth, remote config and audit logging, CLI commands (listen, remote), UI instance tabs/toasts/tab bar, instance orchestration, tests, docs and supporting utilities. No unrelated behaviour changes.

Changes

Cohort / File(s) Summary
Remote core
src/remote/types.ts, src/remote/token.ts, src/remote/config.ts, src/remote/audit.ts, src/remote/server.ts, src/remote/client.ts, src/remote/instance-manager.ts, src/remote/index.ts
New remote subsystem: protocol types, token lifecycle (server & connection tokens), TOML remotes store, JSONL audit logger with rotation, WebSocket server and client implementations, and InstanceManager for tab/connection orchestration.
CLI & run integration
src/commands/listen.ts, src/commands/remote.ts, src/commands/index.ts, src/commands/listen.test.ts, src/commands/remote.test.ts, src/commands/run.tsx, src/cli.tsx
New listen and remote commands (daemon, token rotation, add/list/remove/test/push-config), CLI wiring and run-time --listen support, token displays, daemon PID handling, and tests.
UI: instance navigation & toasts
src/tui/components/TabBar.tsx, src/tui/components/Toast.tsx, src/tui/components/index.ts, src/tui/components/RemoteConfigView.tsx, src/tui/components/RemoteManagementOverlay.tsx
New TabBar for instance tabs with status/metrics, Toast component and formatter for connection events, RemoteConfigView (read-only remote config), and RemoteManagementOverlay (add/edit/delete remotes).
UI: integrate remote state
src/tui/components/RunApp.tsx, src/tui/components/Header.tsx, src/tui/components/ProgressDashboard.tsx, src/tui/components/LeftPanel.tsx, src/tui/components/RightPanel.tsx, src/tui/components/RemoteConfigView.tsx
RunApp extended for instance tabs, remote subscriptions, routing of remote commands and UI switching; Header/ProgressDashboard display remote/git info; panels handle remote connection states.
Types & theme
src/tui/types.ts, src/tui/theme.ts
New ConnectionStatus type and remote-related props; theme adds tabBar layout and keyboard shortcuts for tab switching.
Token/config tests
src/remote/token.test.ts, src/remote/config.test.ts, src/remote/audit.test.ts, tests/remote/remote.test.ts
Extensive tests covering token generation/validation, remotes TOML I/O, audit logging, client/server flows and instance manager behaviour.
Template & examples
src/templates/engine.ts, examples/dates.ts, tests/templates/engine.test.ts
Acceptance-criteria stripping helper added; date utility examples added; tests for AC handling.
Docs & config
README.md, website/content/docs/cli/*.mdx, website/lib/navigation.ts, .factory/droids/*.md, .factory/settings.json
Documentation for remote CLI and run listener, CLI docs additions (remote/doctor/info), README updates, droid specs added and feature flag enabled.
Miscellaneous
.beads/issues.jsonl, src/commands/doctor.test.ts, src/plugins/agents/base.test.ts
Issue tracking entries, improved test typings and test accessors.

Sequence Diagram(s)

sequenceDiagram
    participant User as User/CLI
    participant Listen as Listen Cmd
    participant Server as RemoteServer
    participant Client as RemoteClient
    participant Engine as Engine
    participant UI as RunApp UI

    User->>Listen: run --listen
    Listen->>Listen: Load/create server token
    Listen->>Server: createRemoteServer()
    Server->>Server: Start WebSocket on port
    Server-->>Listen: Server started
    Listen->>UI: Display token & connection info

    rect rgba(100, 200, 100, 0.5)
        Note over User,UI: Connection & Auth Flow
        User->>Client: add remote (host:port, token)
        Client->>Server: Connect WebSocket
        Server->>Server: Accept connection
        Client->>Server: AuthMessage (server token)
        Server->>Server: Validate server token
        Server->>Client: AuthResponseMessage + connection token
        Client->>Client: Store connection token
        Client-->>User: Remote added & connected
    end

    rect rgba(100, 150, 200, 0.5)
        Note over User,Engine: Remote Command Flow
        User->>UI: Switch to remote tab
        UI->>Client: subscribe(event types)
        Client->>Server: SubscribeMessage
        Server->>Engine: Attach listener
        Engine-->>Server: Engine events
        Server->>Client: EngineEventMessage
        Client->>UI: Emit engine_event
        UI->>UI: Update remote state

        User->>UI: pause (on remote)
        UI->>Client: pause()
        Client->>Server: PauseMessage + connection token
        Server->>Engine: Pause execution
        Engine-->>Server: Result
        Server->>Client: OperationResultMessage
        Client-->>UI: Result received
    end

    rect rgba(200, 100, 100, 0.5)
        Note over Client,Server: Token Refresh Flow
        Client->>Server: Ping (check connection)
        Server->>Server: Check connection token expiry
        alt Token near expiry (< threshold)
            Server->>Client: TokenRefreshMessage
            Client->>Client: Issue new connection token
            Client->>Server: TokenRefreshResponseMessage
            Server->>Server: Validate & update
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Poem

🐰 A rabbit's remote dream
Whisker-twitching, tokens dance,
WebSocket threads in sync advance,
Tabs for local, tabs for far,
Instance tabs like stars ⭐,
Remote whispers, local near—
Many burrows, one frontier!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: Remote Instance Monitoring and Control' accurately summarises the main change: adding remote instance monitoring and control capabilities including WebSocket listener, remote commands, tab navigation, and config management.
Docstring Coverage ✅ Passed Docstring coverage is 95.83% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link

codecov bot commented Jan 19, 2026

Codecov Report

❌ Patch coverage is 18.53800% with 3098 lines in your changes missing coverage. Please review.
✅ Project coverage is 40.71%. Comparing base (4382552) to head (afb2806).
⚠️ Report is 24 commits behind head on main.

Files with missing lines Patch % Lines
src/remote/server.ts 2.59% 1087 Missing ⚠️
src/commands/remote.ts 5.86% 546 Missing ⚠️
src/remote/client.ts 33.60% 496 Missing ⚠️
src/remote/instance-manager.ts 51.77% 244 Missing ⚠️
src/remote/token.ts 12.40% 240 Missing ⚠️
src/commands/run.tsx 2.46% 237 Missing ⚠️
src/remote/audit.ts 7.73% 167 Missing ⚠️
src/remote/config.ts 31.77% 73 Missing ⚠️
src/templates/engine.ts 61.90% 8 Missing ⚠️

❌ Your patch status has failed because the patch coverage (18.53%) is below the target coverage (50.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #157      +/-   ##
==========================================
- Coverage   45.96%   40.71%   -5.26%     
==========================================
  Files          63       72       +9     
  Lines       16109    19906    +3797     
==========================================
+ Hits         7405     8105     +700     
- Misses       8704    11801    +3097     
Files with missing lines Coverage Δ
src/remote/index.ts 100.00% <100.00%> (ø)
src/remote/types.ts 100.00% <100.00%> (ø)
src/tui/theme.ts 100.00% <100.00%> (ø)
src/templates/engine.ts 74.50% <61.90%> (-0.82%) ⬇️
src/remote/config.ts 31.77% <31.77%> (ø)
src/remote/audit.ts 7.73% <7.73%> (ø)
src/commands/run.tsx 8.47% <2.46%> (-1.20%) ⬇️
src/remote/token.ts 12.40% <12.40%> (ø)
src/remote/instance-manager.ts 51.77% <51.77%> (ø)
src/remote/client.ts 33.60% <33.60%> (ø)
... and 2 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/tui/components/RunApp.tsx (2)

498-705: Remove debug file writes in UI hot paths.

The /tmp/ralph-keys.log writes are in production code paths (state updates, remote fetch, key handling). This is noisy, can leak user actions, and adds synchronous I/O. Please remove these debug traces.

🧹 Suggested removals
-    import('fs').then(fs => {
-      fs.appendFileSync('/tmp/ralph-keys.log', `[${new Date().toISOString()}] useEffect: isViewingRemote=${isViewingRemote}, instanceManager=${instanceManager ? 'exists' : 'null'}, selectedTabIndex=${selectedTabIndex}, selectedTabStatus=${selectedTabStatus}\n`);
-    });
@@
-    if (selectedTabStatus !== 'connected') {
-      import('fs').then(fs => {
-        fs.appendFileSync('/tmp/ralph-keys.log', `[${new Date().toISOString()}] useEffect: Skipping fetch, tab not connected yet (status=${selectedTabStatus})\n`);
-      });
-      return;
-    }
+    if (selectedTabStatus !== 'connected') return;
@@
-      const fs = await import('fs');
-      // Debug: Check connection status
       const selectedTab = instanceManager.getSelectedTab();
       const client = instanceManager.getSelectedClient();
-      fs.appendFileSync('/tmp/ralph-keys.log', `[${new Date().toISOString()}] fetchRemoteData: Tab=${selectedTab?.label}, Status=${selectedTab?.status}, Client=${client ? 'exists' : 'null'}\n`);
@@
-      fs.appendFileSync('/tmp/ralph-keys.log', `[${new Date().toISOString()}] getRemoteState: ${state ? 'received' : 'null'}\n`);
@@
-      fs.appendFileSync('/tmp/ralph-keys.log', `[${new Date().toISOString()}] getRemoteTasks: ${tasks ? `${tasks.length} tasks` : 'null'}\n`);
@@
-            import('fs').then(fs => {
-              fs.appendFileSync('/tmp/ralph-keys.log', `[${new Date().toISOString()}] 'p' pressed, isViewingRemote=true, displayStatus=${displayStatus}\n`);
-            });
@@
-          import('fs').then(fs => {
-            fs.appendFileSync('/tmp/ralph-keys.log', `[${new Date().toISOString()}] Number key: ${key.name}, tabs: ${instanceTabs?.length}, onSelectTab: ${onSelectTab ? 'exists' : 'null'}\n`);
-          });

Also applies to: 1277-1305, 1530-1549


1392-1433: Wrap the +/= case block in braces to prevent switch-scoping issues.

The const declarations inside the case (isPlus, isMinus, effectiveStatus) can bleed into other cases without braces. Biome's noSwitchDeclarations rule requires wrapping case blocks that contain lexical declarations.

✅ Safe fix
        case '+':
        case '=':
        case '-':
-       case '_':
+       case '_': {
          // Add/remove 10 iterations: +/= add, -/_ remove
          const isPlus = key.name === '+' || key.name === '=';
          const isMinus = key.name === '-' || key.name === '_';
          const effectiveStatus = isViewingRemote ? displayStatus : status;
          if ((isPlus || isMinus) &&
              (effectiveStatus === 'ready' || effectiveStatus === 'running' || effectiveStatus === 'executing' || effectiveStatus === 'paused' || effectiveStatus === 'stopped' || effectiveStatus === 'idle' || effectiveStatus === 'complete')) {
            if (isViewingRemote && instanceManager) {
              // Route to remote instance
              if (isPlus) {
                instanceManager.addRemoteIterations(10);
              } else {
                instanceManager.removeRemoteIterations(10);
              }
            } else {
              // Local engine control
              if (isPlus) {
                engine.addIterations(10).then((shouldContinue) => {
                  if (shouldContinue || status === 'complete') {
                    setStatus('running');
                    engine.continueExecution();
                  }
                }).catch((err) => {
                  console.error('Failed to add iterations:', err);
                });
              } else {
                engine.removeIterations(10)
                  .then((success) => {
                    if (!success) {
                      console.log('Cannot reduce below current iteration or minimum of 1');
                    }
                  })
                  .catch((err) => {
                    console.error('Failed to remove iterations:', err);
                  });
              }
            }
          }
          break;
+       }
🤖 Fix all issues with AI agents
In `@src/commands/remote.ts`:
- Around line 89-155: testRemoteConnection currently leaves the WebSocket open
when the timeout fires and may call resolve multiple times; modify the function
so the WebSocket (ws) is declared in the outer scope, introduce a single
"settled" guard (boolean) to ensure resolve is only called once, and in every
resolution path (timeout handler, ws.onopen/onmessage/ws.onerror/ws.onclose and
the catch block) clear the timeout, set settled=true, remove/ignore event
handlers, and call ws.close() if ws exists; ensure error paths include the same
cleanup so the socket never stays open.

In `@src/commands/run.tsx`:
- Around line 673-705: Replace the ad-hoc useState side-effect block with a
useEffect that runs once ([]) and inside it register the instanceManager
listeners (instanceManager.onStateChange and instanceManager.onToast), call
instanceManager.initialize() and set instanceTabs/selectedTabIndex from
instanceManager.getTabs() / getSelectedIndex(); remove all debug
fs.appendFileSync imports/uses (including those in handleSelectTab) so no file
writes remain. In the useEffect cleanup return a function that calls
instanceManager.disconnectAll() to tear down connections when the component
unmounts. Keep the existing handleSelectTab logic to call
instanceManager.selectTab(index) but remove the dynamic fs import and logging.

In `@src/remote/client.ts`:
- Around line 964-981: The reconnection onmessage handler for 'auth_response'
(inside this.ws.onmessage) fails to store the returned connectionToken and
connectionTokenExpiresAt, so update the successful-auth branch in that handler
to set this.connectionToken = authResponse.connectionToken and
this.connectionTokenExpiresAt = authResponse.connectionTokenExpiresAt (mirroring
the logic in handleMessage that sets those fields), then proceed with setting
this._status, this._connectedAt, startMetricsInterval(), emit the 'reconnected'
event, reset this.reconnectAttempts, and startPingInterval so the reconnected
session retains the token for future re-authentication.

In `@src/remote/config.test.ts`:
- Around line 97-100: The test is wrong to accept decimal ports because
parseInt('1.5') truncates; update parseHostPort to reject any non-integer port
string instead of truncating: in parseHostPort, validate the port token with a
strict integer check (e.g., /^\d+$/ or Number.isInteger(Number(portStr)) &&
String(Number(portStr)) === portStr) and return an error/invalid result (or
throw) for values like '1.5'; then update the test expectation to assert that
parseHostPort('host:1.5') is treated as invalid (matches the new invalid/throw
behavior). Ensure you only change logic in parseHostPort and corresponding test
assertion.

In `@src/remote/instance-manager.ts`:
- Around line 332-334: Remove the temporary debug writes that use dynamic
import('fs').then(...) to append to '/tmp/ralph-keys.log' (the blocks that
construct a timestamped "engine_event received" message referencing
event.event.type, tab.alias, this.tabs[this.selectedIndex]?.alias, etc.); delete
those import('fs')... appendFileSync calls across the file and replace them with
calls to the existing logging facility (e.g., processLogger or the module's
logger) at an appropriate level if persistent logging is required, ensuring no
sensitive tokens are written and avoiding writes to /tmp and async dynamic fs
imports.

In `@src/tui/components/TabBar.tsx`:
- Around line 79-209: The TabBar currently ignores the onSelectTab and
onAddRemote callbacks declared in TabBarProps; update TabBar to destructure
onSelectTab and onAddRemote and pass them down to Tab and AddRemoteButton
(reference TabBar, Tab, AddRemoteButton, onSelectTab, onAddRemote). Modify Tab
to accept an onSelect prop (or onSelectTab) and call it when its box/text is
activated (e.g., click/press handler supported by the UI library) using the
tab.id or index, and modify AddRemoteButton to accept onAddRemote and invoke it
on activation; alternatively, if click handlers aren't supported, remove
onSelectTab/onAddRemote from TabBarProps and any caller usage to keep the API
consistent.
🧹 Nitpick comments (16)
README.md (1)

189-201: Consider adding a language specifier to the fenced code block.

The ASCII diagram doesn't require syntax highlighting, but adding a specifier (e.g., text or plaintext) would satisfy markdown linters and maintain consistency.

📝 Suggested fix
-```
+```text
 ┌─────────────────────────────────────────────────────────────────┐
 │  LOCAL [1]│ ● prod [2]│ ◐ staging [3]│ ○ dev [4]│      +       │
 ├─────────────────────────────────────────────────────────────────┤
src/remote/audit.ts (3)

30-33: Use dirname() instead of join(logPath, '..').

The current approach works but is less idiomatic. Using dirname() from node:path is clearer and more robust.

♻️ Suggested fix
+import { join, dirname } from 'node:path';
-import { join } from 'node:path';
 
 // In constructor:
-    this.logDir = join(logPath, '..');
+    this.logDir = dirname(logPath);

149-170: TOCTOU race condition in rotation logic.

There's a time-of-check to time-of-use gap between stat() and rename(). If multiple processes write concurrently, both could attempt rotation simultaneously. For a single-instance audit logger this is unlikely to cause issues, but consider documenting this limitation or using a lock file for multi-process scenarios.


154-154: Unnecessary dynamic import.

rename and unlink are already available from the static import at line 9. The dynamic import adds overhead and inconsistency.

♻️ Suggested fix
-import { appendFile, mkdir, readFile, stat } from 'node:fs/promises';
+import { appendFile, mkdir, readFile, stat, rename, unlink } from 'node:fs/promises';

// In rotateIfNeeded:
-        const { rename, unlink } = await import('node:fs/promises');
         const oldPath = this.logPath + '.old';
src/remote/types.ts (1)

250-258: Move imports to top of file.

The imports at line 250 break the convention of placing all imports at the top of the file. This makes dependencies harder to scan and could cause issues with some bundlers or linters.

♻️ Suggested fix

Move these imports to the top of the file after the existing imports:

import type { TrackerTask } from '../plugins/trackers/types.js';
import type {
  EngineEvent,
  EngineStatus,
  IterationResult,
  ActiveAgentState,
  RateLimitState,
  SubagentTreeNode,
} from '../engine/types.js';
website/content/docs/cli/listen.mdx (1)

29-29: Minor grammatical improvement.

Consider adding the definite article for better readability.

-On first run, a secure authentication token is generated:
+On the first run, a secure authentication token is generated:
src/commands/remote.test.ts (1)

160-171: Restore console.log via try/finally to avoid cross-test leakage.

If printRemoteHelp() throws or an assertion fails, console.log remains mocked and can taint later tests.

♻️ Suggested fix
-    const logs: string[] = [];
-    const originalLog = console.log;
-    console.log = (...args: unknown[]) => logs.push(args.join(' '));
-
-    expect(() => printRemoteHelp()).not.toThrow();
-
-    // Restore console.log
-    console.log = originalLog;
+    const logs: string[] = [];
+    const originalLog = console.log;
+    try {
+      console.log = (...args: unknown[]) => logs.push(args.join(' '));
+      expect(() => printRemoteHelp()).not.toThrow();
+    } finally {
+      console.log = originalLog;
+    }
src/tui/components/Header.tsx (1)

234-242: Guard against long remote labels to prevent header overflow.

Long aliases or hostnames can push the status and task text off-screen. Consider truncating the remote name/host in the header.

♻️ Suggested tweak
-            <text>
-              <span fg={colors.accent.primary}>{REMOTE_ICON} {remoteInfo.name}</span>
-              <span fg={colors.fg.dim}> ({remoteInfo.host}:{remoteInfo.port})</span>
+            <text>
+              <span fg={colors.accent.primary}>
+                {REMOTE_ICON} {truncateText(remoteInfo.name, 14)}
+              </span>
+              <span fg={colors.fg.dim}>
+                ({truncateText(`${remoteInfo.host}:${remoteInfo.port}`, 24)})
+              </span>
               <span fg={colors.fg.dim}> │ </span>
             </text>
src/commands/listen.test.ts (1)

108-126: Use try/finally when mocking console.log.

If the assertion fails, console.log stays mocked and can affect later tests.

♻️ Suggested fix
-    const logs: string[] = [];
-    const originalLog = console.log;
-    console.log = (...args: unknown[]) => logs.push(args.join(' '));
-
-    expect(() => printListenHelp()).not.toThrow();
-
-    console.log = originalLog;
+    const logs: string[] = [];
+    const originalLog = console.log;
+    try {
+      console.log = (...args: unknown[]) => logs.push(args.join(' '));
+      expect(() => printListenHelp()).not.toThrow();
+    } finally {
+      console.log = originalLog;
+    }
src/tui/components/Toast.tsx (1)

41-51: Add a defensive default in getVariantColors.

If a variant is ever cast from untyped input, the function could return undefined. A default keeps behaviour safe and predictable.

♻️ Suggested fix
 function getVariantColors(variant: ToastVariant): { border: string; text: string } {
   switch (variant) {
     case 'success':
       return { border: colors.status.success, text: colors.status.success };
     case 'warning':
       return { border: colors.status.warning, text: colors.status.warning };
     case 'error':
       return { border: colors.status.error, text: colors.status.error };
     case 'info':
       return { border: colors.status.info, text: colors.status.info };
+    default:
+      return { border: colors.status.info, text: colors.status.info };
   }
 }
src/remote/remote.test.ts (1)

347-381: Consider centralising the message type list to avoid drift.

The hard-coded messageTypes array can fall out of sync with protocol changes. A shared constant (or deriving from a single source) would reduce maintenance overhead.

src/remote/token.ts (2)

222-238: Constant-time comparison has a timing leak when string lengths differ.

The implementation attempts constant-time comparison, but when a.length !== b.length, it performs a different amount of work (iterating over b.length elements) before returning false. An attacker could potentially infer token length by measuring timing differences.

A more robust approach would pad both strings to the maximum expected length or use Node's built-in crypto.timingSafeEqual:

♻️ Suggested improvement using crypto.timingSafeEqual
+import { timingSafeEqual } from 'node:crypto';
+
 /**
  * Constant-time string comparison to prevent timing attacks.
  */
 function constantTimeCompare(a: string, b: string): boolean {
-  if (a.length !== b.length) {
-    // Still do a comparison to maintain constant time
-    // The _unused variable prevents timing attacks by doing work even when lengths differ
-    let _unused = 0;
-    for (let i = 0; i < b.length; i++) {
-      _unused |= b.charCodeAt(i) ^ (a.charCodeAt(i % a.length) || 0);
-    }
-    return false;
-  }
-
-  let result = 0;
-  for (let i = 0; i < a.length; i++) {
-    result |= a.charCodeAt(i) ^ b.charCodeAt(i);
-  }
-  return result === 0;
+  const aBuffer = Buffer.from(a);
+  const bBuffer = Buffer.from(b);
+  
+  if (aBuffer.length !== bBuffer.length) {
+    // Compare against self to maintain constant time, then return false
+    timingSafeEqual(aBuffer, aBuffer);
+    return false;
+  }
+  
+  return timingSafeEqual(aBuffer, bBuffer);
 }

280-285: Consider periodic automatic cleanup of expired connection tokens.

The activeConnectionTokens Map stores connection tokens in memory, and while cleanupExpiredTokens() exists, it must be called manually. For long-running server processes, this could lead to memory growth if cleanup isn't invoked regularly.

Consider setting up an automatic cleanup interval when the first token is issued:

♻️ Suggested automatic cleanup
let cleanupInterval: ReturnType<typeof setInterval> | null = null;

function startCleanupInterval(): void {
  if (!cleanupInterval) {
    // Clean up every hour
    cleanupInterval = setInterval(cleanupExpiredTokens, 60 * 60 * 1000);
  }
}

export function issueConnectionToken(clientId: string): ConnectionToken {
  startCleanupInterval();
  // ... rest of implementation
}
src/remote/client.ts (2)

259-262: Consider supporting secure WebSocket connections (WSS).

The client uses unencrypted ws:// for connections. For a remote monitoring and control system that transmits authentication tokens and can execute commands (pause, interrupt, config push), encrypted connections should be strongly considered, especially when connecting over untrusted networks.

♻️ Suggested improvement to support WSS
+  private useSecure: boolean;
+
   constructor(
     host: string,
     port: number,
     token: string,
     eventHandler: RemoteClientEventHandler,
-    reconnectConfig: Partial<ReconnectConfig> = {}
+    reconnectConfig: Partial<ReconnectConfig> = {},
+    useSecure = false
   ) {
     this.host = host;
     this.port = port;
     this.serverToken = token;
     this.eventHandler = eventHandler;
     this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...reconnectConfig };
+    this.useSecure = useSecure;
   }

   // In connect():
-  const url = `ws://${this.host}:${this.port}`;
+  const protocol = this.useSecure ? 'wss' : 'ws';
+  const url = `${protocol}://${this.host}:${this.port}`;

Is there a reason WSS support was not included? Please verify whether the deployment scenarios require encrypted connections.


268-275: Consider logging JSON parse errors for debugging.

The empty catch block silently ignores malformed messages. While ignoring invalid messages is reasonable, logging these at debug level would aid troubleshooting protocol issues or malicious input.

         this.ws.onmessage = (event) => {
           try {
             const message = JSON.parse(event.data as string) as WSMessage;
             this.handleMessage(message, resolve, reject);
-          } catch {
-            // Ignore invalid messages
+          } catch (error) {
+            // Log but don't fail on invalid messages
+            console.debug('Failed to parse WebSocket message:', error);
           }
         };
src/remote/instance-manager.ts (1)

374-379: Fragile assumption in isViewingRemote().

The method assumes the local tab is always at index 0. If tab ordering ever changes, this logic would break silently.

♻️ Suggested improvement for robustness
   isViewingRemote(): boolean {
-    return this.selectedIndex > 0;
+    const selectedTab = this.tabs[this.selectedIndex];
+    return selectedTab ? !selectedTab.isLocal : false;
   }

- Remove standalone `ralph-tui listen` command
- Add `--listen`, `--listen-port`, and `--rotate-token` flags to run command
- Move listener documentation from listen.mdx to run.mdx
- Update CLI help, README, and all docs references
- Update navigation to remove listen page

BREAKING CHANGE: `ralph-tui listen` is no longer available.
Use `ralph-tui run --listen` instead.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/commands/run.tsx`:
- Around line 1804-1807: The signal handlers in executeRunCommand / runHeadless
never call remoteServer.stop(), so when --listen the server isn’t gracefully
shut down; update the shutdown flow to ensure remoteServer.stop() is invoked
before process.exit(): either pass remoteServer into runHeadless (so its
gracefulShutdown can await remoteServer.stop()), or extract a shared cleanup
function (e.g., stopRemoteServer) that both the normal exit path and the signal
handlers call, and ensure gracefulShutdown/executeRunCommand awaits that
function before calling process.exit(). Reference runHeadless, gracefulShutdown,
executeRunCommand, and remoteServer when applying the change.

In `@website/content/docs/cli/remote.mdx`:
- Around line 217-218: The related-commands list references a non-existent
`listen` doc; either remove the broken list item "`[`listen`](/docs/cli/listen)
- Start a remote listener on a server`" from the Related Commands section in
remote.mdx, or add a new `listen` CLI doc that exports appropriate frontmatter
and content (matching the style of other CLI pages) so the `/docs/cli/listen`
path resolves; ensure the `run --listen` entry (`run --listen`) remains
unchanged.
♻️ Duplicate comments (1)
src/commands/run.tsx (1)

687-713: Move instance-manager initialisation to useEffect with cleanup; remove debug file writes.

This code has multiple issues:

  1. Non-idiomatic React: Using useState(() => {...}) for side effects violates React conventions. The initialiser should be pure and only compute the initial state value.

  2. Debug logging to /tmp/ralph-keys.log: Lines 690-692 and 709-712 write to a hardcoded file path. This is leftover debug code that should be removed before merging—it's a potential security/privacy concern and causes unnecessary I/O.

  3. Missing cleanup: When the component unmounts, instanceManager.disconnectAll() is never called, which can leak connections.

🛠️ Suggested fix
+import { useEffect } from 'react';
+
   // Remote instance management
   const [instanceManager] = useState(() => new InstanceManager());
   const [instanceTabs, setInstanceTabs] = useState<InstanceTab[]>([]);
   const [selectedTabIndex, setSelectedTabIndex] = useState(0);
   const [connectionToast, setConnectionToast] = useState<ConnectionToastMessage | null>(null);

   // Initialize instance manager on mount
-  useState(() => {
-    instanceManager.onStateChange((tabs, selectedIndex) => {
-      import('fs').then(fs => {
-        fs.appendFileSync('/tmp/ralph-keys.log', `[${new Date().toISOString()}] onStateChange: selectedIndex=${selectedIndex}, tabs=${tabs.length}\n`);
-      });
-      setInstanceTabs(tabs);
-      setSelectedTabIndex(selectedIndex);
-    });
-    instanceManager.onToast((toast) => {
-      setConnectionToast(toast as ConnectionToastMessage);
-      // Auto-clear toast after 3 seconds
-      setTimeout(() => setConnectionToast(null), 3000);
-    });
-    instanceManager.initialize().then(() => {
-      setInstanceTabs(instanceManager.getTabs());
-      setSelectedTabIndex(instanceManager.getSelectedIndex());
-    });
-  });
+  useEffect(() => {
+    instanceManager.onStateChange((tabs, selectedIndex) => {
+      setInstanceTabs(tabs);
+      setSelectedTabIndex(selectedIndex);
+    });
+    instanceManager.onToast((toast) => {
+      setConnectionToast(toast as ConnectionToastMessage);
+      setTimeout(() => setConnectionToast(null), 3000);
+    });
+    void instanceManager.initialize().then(() => {
+      setInstanceTabs(instanceManager.getTabs());
+      setSelectedTabIndex(instanceManager.getSelectedIndex());
+    });
+    return () => {
+      void instanceManager.disconnectAll();
+    };
+  }, [instanceManager]);

   // Handle tab selection
   const handleSelectTab = async (index: number): Promise<void> => {
-    const fs = await import('fs');
-    fs.appendFileSync('/tmp/ralph-keys.log', `[${new Date().toISOString()}] handleSelectTab called with index: ${index}\n`);
     await instanceManager.selectTab(index);
-    fs.appendFileSync('/tmp/ralph-keys.log', `[${new Date().toISOString()}] selectTab completed\n`);
   };
🧹 Nitpick comments (4)
website/content/docs/cli/run.mdx (1)

250-253: Consider adding a note about the JSONL format.

The audit log example shows JSONL format, but users unfamiliar with this format might benefit from a brief note that each line is a separate JSON object.

📝 Suggested clarification
 ### Audit Logging

-All remote actions are logged to `~/.config/ralph-tui/audit.log`:
+All remote actions are logged to `~/.config/ralph-tui/audit.log` in JSONL format (one JSON object per line):
README.md (2)

193-201: Consider adding a language identifier to the fenced code block.

The ASCII diagram code block has no language specified. While ASCII art diagrams don't have a standard language, you can use text or leave it empty to satisfy the linter.

📝 Suggested fix
-```
+```text
 ┌─────────────────────────────────────────────────────────────────┐
 │  LOCAL [1]│ ● prod [2]│ ◐ staging [3]│ ○ dev [4]│      +       │

360-365: Clarify the distinction between remote.json and remotes.toml.

The configuration files table lists both remote.json (server token storage) and remotes.toml (remote server configurations). This is potentially confusing—consider clarifying that remote.json stores the local server's token when running as a listener, whilst remotes.toml stores configurations for connecting to remote servers.

📝 Suggested clarification
 | File | Purpose |
 |------|---------|
-| `~/.config/ralph-tui/remote.json` | Server token storage |
-| `~/.config/ralph-tui/remotes.toml` | Remote server configurations |
+| `~/.config/ralph-tui/remote.json` | Local server token (when running with `--listen`) |
+| `~/.config/ralph-tui/remotes.toml` | Remote server connection configs (client-side) |
 | `~/.config/ralph-tui/audit.log` | Audit log of all remote actions |
 | `~/.config/ralph-tui/listen.pid` | Daemon PID file |
src/commands/run.tsx (1)

1376-1386: Consider adding a comment explaining the intentional blocking.

The never-resolving promise pattern is valid for keeping the process alive, but the code structure is slightly confusing: await engine.dispose() on line 1388 becomes unreachable in listen mode. While the signal handlers do call engine.dispose() before process.exit(), a brief comment would clarify the intent.

💡 Optional clarification
   // In listen mode, keep process alive for remote connections
   if (listenMode) {
     logger.info('system', 'Engine idle. Waiting for remote commands (Ctrl+C to stop)...');

-    // Keep process alive until signal received
+    // Block indefinitely until SIGINT/SIGTERM triggers gracefulShutdown,
+    // which handles engine.dispose() before calling process.exit().
     await new Promise<void>((_resolve) => {
-      // The existing SIGINT/SIGTERM handlers will call process.exit()
-      // This promise just keeps the event loop alive indefinitely
     });
   }

subsy added 3 commits January 19, 2026 20:11
- Server tries up to 10 ports (configurable via maxPortRetries)
- Shows "(requested X was in use)" when bound to different port
- Logs requestedPort in audit when fallback occurs
- Exposes actualPort getter on RemoteServer
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@examples/dates.ts`:
- Around line 38-41: The 'full' branch in the switch uses d.toLocaleString()
which defaults to the runtime locale and is inconsistent with the 'date' branch
that passes 'en-US'; update the 'full' case (the switch handling with case
'full' / default) to call toLocaleString with the same locale ('en-US' or the
shared locale variable used for 'date') so both branches use a consistent
locale.
- Around line 1-3: Replace the single-line ABOUTME comment block with a
file-level JSDoc comment: convert the leading lines that start with "// ABOUTME:
Date utility functions..." into a JSDoc block /** ... */ at the top of the file,
preserving the same text (ISO 8601 storage, manual duration math, pure
JavaScript Date API) and formatting as a proper file header so tools and linters
recognize it; ensure the JSDoc block appears before any imports or code in
examples/dates.ts and contains the original ABOUTME content.

In `@src/remote/server.ts`:
- Around line 769-795: The handler must validate the incoming iteration count
before calling the engine: in handleAddIterations check that message.count is a
number, is an integer (Number.isInteger) and is > 0, and if not call
this.sendOperationError(ws, message.id, 'add_iterations', 'Invalid iteration
count') and return early; do the same validation in the corresponding remove
handler (e.g., handleRemoveIterations / operation name 'remove_iterations') to
reject zero/negative or non-integer counts up-front instead of passing them to
this.options.engine.
- Around line 432-444: getState() currently returns this.options.port even when
the server actually bound to a different port; change getState() to prefer the
bound port (this._actualPort) when present and fall back to this.options.port so
status consumers see the real port. Locate the getState() method and set the
port property to this._actualPort if defined (or null/undefined check) otherwise
use this.options.port, ensuring the returned RemoteServerState.port reflects the
actual listening port.
♻️ Duplicate comments (2)
src/commands/run.tsx (2)

681-713: Move instance-manager side-effects into useEffect and drop debug file writes.

Side-effects during render plus /tmp/ralph-keys.log writes are risky and leave no cleanup. Use useEffect with a cleanup that calls disconnectAll(), and remove the file writes.

🔧 Proposed fix
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
-  // Initialize instance manager on mount
-  useState(() => {
-    instanceManager.onStateChange((tabs, selectedIndex) => {
-      import('fs').then(fs => {
-        fs.appendFileSync('/tmp/ralph-keys.log', `[${new Date().toISOString()}] onStateChange: selectedIndex=${selectedIndex}, tabs=${tabs.length}\n`);
-      });
-      setInstanceTabs(tabs);
-      setSelectedTabIndex(selectedIndex);
-    });
-    instanceManager.onToast((toast) => {
-      setConnectionToast(toast as ConnectionToastMessage);
-      // Auto-clear toast after 3 seconds
-      setTimeout(() => setConnectionToast(null), 3000);
-    });
-    instanceManager.initialize().then(() => {
-      setInstanceTabs(instanceManager.getTabs());
-      setSelectedTabIndex(instanceManager.getSelectedIndex());
-    });
-  });
+  // Initialise instance manager on mount
+  useEffect(() => {
+    instanceManager.onStateChange((tabs, selectedIndex) => {
+      setInstanceTabs(tabs);
+      setSelectedTabIndex(selectedIndex);
+    });
+    instanceManager.onToast((toast) => {
+      setConnectionToast(toast as ConnectionToastMessage);
+      setTimeout(() => setConnectionToast(null), 3000);
+    });
+    void instanceManager.initialize().then(() => {
+      setInstanceTabs(instanceManager.getTabs());
+      setSelectedTabIndex(instanceManager.getSelectedIndex());
+    });
+    return () => {
+      void instanceManager.disconnectAll();
+    };
+  }, [instanceManager]);
@@
   const handleSelectTab = async (index: number): Promise<void> => {
-    const fs = await import('fs');
-    fs.appendFileSync('/tmp/ralph-keys.log', `[${new Date().toISOString()}] handleSelectTab called with index: ${index}\n`);
     await instanceManager.selectTab(index);
-    fs.appendFileSync('/tmp/ralph-keys.log', `[${new Date().toISOString()}] selectTab completed\n`);
   };

1377-1385: Listen-mode shutdown bypasses remote and session cleanup.

In listen mode, runHeadless keeps the process alive and its signal handlers call process.exit(), so the cleanup after runHeadless (including remoteServer.stop(), endSession, and lock release) never runs. Consider a shared shutdown path (or pass remoteServer into runHeadless) so the signal flow awaits cleanup before exiting.

Also applies to: 1809-1812

- Fix auto-commit status not updating when switching to remote instances
  by propagating autoCommit through server options and RemoteEngineState
- Add read-only config viewer (Shift+C) for both local and remote tabs
  with tabbed view for global/project configs and scroll navigation
- Fix token display to only show full token on first run/rotation,
  showing preview (first 8 chars) on subsequent runs
- Fix port retry logic to detect Bun's EADDRINUSE error format
  (checks error.code property in addition to message text)
- Fix reconnection bugs: prevent double-counting retries when both
  onerror and onclose fire, clear invalid connection token on auth
  failure to allow fallback to server token
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@README.md`:
- Around line 148-149: The README entry for the keybindings is ambiguous about
the `C` shortcut; update the table row containing "``,` / `C` | Open settings
(local) or view remote config (remote)`" to clearly state that Shift+C opens the
read‑only config viewer for both local and remote tabs (e.g., "`,` / `C` | Open
settings (local) or open read‑only config viewer (Shift+C) for local or
remote"). Ensure the change references the `,` / `C` key labels and the Shift+C
behavior so readers understand it applies to both local and remote configs.

In `@src/tui/components/RunApp.tsx`:
- Around line 509-535: The effect and its helper fetchRemoteData in RunApp.tsx
contain synchronous fs.appendFileSync calls (inside useEffect and
fetchRemoteData, referencing instanceManager, selectedTabStatus, getRemoteState)
which block the render loop and are unsafe on non-UNIX platforms; remove these
blocking writes or gate them behind an explicit debug flag (e.g.
process.env.DEBUG or a runtime isDebug boolean) and switch to non-blocking async
writes (fs.promises.appendFile) or a platform-safe logging helper that writes to
os.tmpdir() (use path/os APIs), and apply the same change to the other debug
write sites referenced in the review (the other debug blocks around the keypress
handlers and later effects).
♻️ Duplicate comments (6)
src/remote/client.ts (1)

964-977: Persist the connection token on successful reconnect.
The reconnect auth_response success path still doesn’t store connectionToken/expiry or re-arm refresh, so a reconnected session can miss token refresh.

🔧 Proposed fix
           if (authResponse.success) {
             this._status = 'connected';
             this._connectedAt = new Date().toISOString();
+            // Store connection token if provided
+            if (authResponse.connectionToken && authResponse.connectionTokenExpiresAt) {
+              this.connectionToken = authResponse.connectionToken;
+              this.connectionTokenExpiresAt = authResponse.connectionTokenExpiresAt;
+              this.scheduleTokenRefresh();
+            }
             this.startMetricsInterval();
             this.eventHandler({ type: 'reconnected', totalAttempts: this.reconnectAttempts });
             this.reconnectAttempts = 0;
             this.startPingInterval();
src/remote/server.ts (2)

448-452: Return the actual bound port in getState().
When a fallback port is used, getState() still reports options.port. Prefer _actualPort to keep status accurate.

🔧 Proposed fix
-      port: this.options.port,
+      port: this._actualPort ?? this.options.port,

782-826: Validate iteration counts from remote clients.
count can be zero/negative or non-integer, which can lead to unexpected engine behaviour. Reject invalid values up-front.

🔧 Proposed fix
   private async handleAddIterations(
     ws: ServerWebSocket<WebSocketData>,
     message: AddIterationsMessage
   ): Promise<void> {
+    if (!Number.isInteger(message.count) || message.count <= 0) {
+      this.sendOperationError(ws, message.id, 'add_iterations', 'count must be a positive integer');
+      return;
+    }
     if (!this.options.engine) {
       this.sendOperationError(ws, message.id, 'add_iterations', 'No engine attached');
       return;
     }
@@
   private async handleRemoveIterations(
     ws: ServerWebSocket<WebSocketData>,
     message: RemoveIterationsMessage
   ): Promise<void> {
+    if (!Number.isInteger(message.count) || message.count <= 0) {
+      this.sendOperationError(ws, message.id, 'remove_iterations', 'count must be a positive integer');
+      return;
+    }
     if (!this.options.engine) {
       this.sendOperationError(ws, message.id, 'remove_iterations', 'No engine attached');
       return;
     }

Also applies to: 813-839

src/commands/run.tsx (3)

688-713: Debug logging and non-idiomatic React pattern still present.

The issues previously identified remain unaddressed:

  1. Using useState(() => { ... }) for side effects is non-idiomatic and bypasses proper React lifecycle management
  2. Debug file writes to /tmp/ralph-keys.log (lines 690-692, 709-712) must be removed

Move the initialisation to useEffect with a cleanup function that calls disconnectAll(), and remove all debug fs.appendFileSync calls.


1376-1386: Unreachable cleanup code after never-resolving promise.

When listenMode is true, this promise never resolves and the signal handlers call process.exit() directly. This means:

  1. Line 1388 (engine.dispose()) is unreachable in listen mode
  2. runHeadless never returns, so remoteServer.stop() in executeRunCommand (line 1816) is never called

The signal handlers properly dispose the engine, but the remote server cleanup is bypassed. Consider passing remoteServer into runHeadless or refactoring the shutdown flow to ensure remoteServer.stop() is called.


1814-1817: remoteServer.stop() is bypassed in listen mode.

As noted in the review of lines 1376-1386, when --listen is active, runHeadless blocks indefinitely and the signal handlers call process.exit() directly, meaning this cleanup code is never reached.

While the OS cleans up sockets on process termination, explicitly stopping the server ensures graceful shutdown logic (audit logging, client notifications) executes correctly.

🧹 Nitpick comments (1)
src/tui/components/RemoteConfigView.tsx (1)

97-115: Clamp scroll offset to the content length.
j/G can drive scrollOffset past the end of the file, leaving blank content and making “bottom” approximate. Consider clamping against a computed max offset and reusing a single page-size constant.

♻️ Suggested refactor
-  const contentLines = currentContent?.split('\n') ?? [];
-  const visibleLines = contentLines.slice(scrollOffset, scrollOffset + 20);
-  const canScrollDown = scrollOffset + 20 < contentLines.length;
-  const canScrollUp = scrollOffset > 0;
+  const pageSize = 20;
+  const contentLines = currentContent?.split('\n') ?? [];
+  const maxOffset = Math.max(0, contentLines.length - pageSize);
+  const clampedOffset = Math.min(scrollOffset, maxOffset);
+  const visibleLines = contentLines.slice(clampedOffset, clampedOffset + pageSize);
+  const canScrollDown = clampedOffset < maxOffset;
+  const canScrollUp = clampedOffset > 0;

Also applies to: 142-146

- Fix testRemoteConnection WebSocket cleanup: add settled guard and proper
  cleanup in all resolution paths to prevent resource leaks
- Replace ad-hoc useState with useEffect for instance manager initialization,
  add cleanup on unmount via instanceManager.disconnectAll()
- Remove all debug fs writes to /tmp/ralph-keys.log from run.tsx, RunApp.tsx,
  and instance-manager.ts
- Store connectionToken on successful reconnection to maintain auth for future
  reconnects, also clear connectionTokenExpiresAt on auth failure
- Reject decimal ports in parseHostPort (e.g., '1.5') by validating with regex
  /^\d+$/ instead of relying on parseInt truncation
- Remove unused onSelectTab/onAddRemote from TabBarProps (keyboard navigation
  handled by parent component, terminal UI doesn't support pointer events)
- Call remoteServer.stop() in signal handlers (gracefulShutdown, handleSigterm)
  by passing remoteServer through HeadlessOptions
- Fix broken listen doc reference in remote.mdx (removed non-existent link)
- Return actual bound port in server.ts getState() instead of requested port
- Add iteration count validation in handleAddIterations and handleRemoveIterations
  to reject non-integer or non-positive values
- Add RemoteManagementOverlay component for add/edit/delete remotes
- Add keyboard shortcuts: 'a' to add, 'e' to edit, 'x' to delete remote
- Extend InstanceManager with connect/disconnect/reconnect methods
- Show connection status and helpful messages for disconnected remotes
- Display local git info (repo:branch*) in dashboard
- Always show sandbox status with padlock icons (🔒/🔓)
- Disable epic loader ('l') for remote instances with info message
- Redesign dashboard with grid layout for better organization
  - Left column: status, remote info, epic, task
  - Right column: agent/model, tracker, git, sandbox/commit
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/tui/components/RunApp.tsx`:
- Around line 1723-1724: The handleKeyboard useCallback is missing displayStatus
and instanceManager in its dependency array, causing stale closures for remote
controls; update the dependency array for the handleKeyboard callback (function
handleKeyboard) to include displayStatus and instanceManager so that
pause/resume/continue and other remote actions use the latest state, and re-run
relevant UI interaction flows to verify the handler reacts to remote status
changes.
♻️ Duplicate comments (1)
README.md (1)

148-149: Clarify the C shortcut behaviour.

The current wording "Open settings (local) or view remote config (remote)" is ambiguous. Based on the PR description, Shift+C opens a read-only config viewer for both local and remote tabs.

✏️ Suggested doc tweak
-| `,` / `C` | Open settings (local) or view remote config (remote) |
+| `,` | Open settings |
+| `C` | View config (read-only, local or remote) |
🧹 Nitpick comments (7)
src/remote/instance-manager.ts (1)

374-376: Consider documenting the tab ordering invariant.

The method assumes the local tab is always at index 0. While this is established in initialize() and maintained by other methods, consider adding a brief comment to document this invariant for future maintainers.

src/tui/components/LeftPanel.tsx (1)

103-106: Import ConnectionStatus from ../types.js instead of redefining locally.

This type is already exported from src/tui/types.ts (line 159-160) with identical values. Importing it would reduce duplication and ensure consistency if the type definition changes.

♻️ Proposed fix
-import type { LeftPanelProps, TaskItem } from '../types.js';
+import type { LeftPanelProps, TaskItem, ConnectionStatus } from '../types.js';

Then remove the local type definition at lines 103-106.

src/tui/types.ts (1)

157-160: Consider consolidating ConnectionStatus type definitions.

This type is defined in multiple places:

  • src/tui/types.ts (here)
  • src/remote/client.ts (same values, different order)
  • src/tui/components/LeftPanel.tsx (local copy)

While the values are semantically identical, having a single source of truth would reduce maintenance burden. Consider either:

  1. Re-exporting from src/remote/client.ts if this is the canonical source
  2. Creating a shared types module for cross-layer types
src/remote/server.ts (3)

161-170: Consider using crypto.randomUUID() for stronger ID generation.

Math.random() is not cryptographically secure. While client IDs and message IDs aren't security-critical tokens, using crypto.randomUUID() (available in Bun) would be more robust and eliminate any potential for ID collisions in high-throughput scenarios.

♻️ Suggested improvement
-function generateClientId(): string {
-  return Math.random().toString(36).substring(2, 10);
-}
-
-function generateMessageId(): string {
-  return Math.random().toString(36).substring(2, 15);
-}
+function generateClientId(): string {
+  return crypto.randomUUID().slice(0, 8);
+}
+
+function generateMessageId(): string {
+  return crypto.randomUUID();
+}

1186-1201: Migration error is silently swallowed.

The fire-and-forget migration pattern catches errors but does nothing with them. Consider logging the failure for debugging purposes, even if the config write itself succeeded.

♻️ Suggested improvement
     // Run migration in background - don't await
     checkAndMigrate(cwd, { quiet: true }).then((result) => {
       if (result?.migrated) {
-        // Migration was performed
+        // Migration was performed - could emit an event or log
       }
     }).catch(() => {
-      // Migration failed, but config was still written successfully
+      // Migration failed, but config was still written successfully
+      // Consider: this.auditLogger.logFailure(clientId, 'push_config_migration', 'Migration failed');
     });

765-787: Async operation sends response after promise resolution but doesn't await audit logging.

The handleInterrupt method correctly awaits engine.stop() before sending the response. However, unlike other handlers, any audit logging for this operation appears to be missing.

src/tui/components/RemoteManagementOverlay.tsx (1)

415-419: Token masking shows fixed 8 asterisks when empty.

When the token field is empty and hidden, '*'.repeat(token.length || 8) displays 8 asterisks. This is a reasonable placeholder length, though it could mislead users about expected token length. Consider whether this is intentional UX or if showing "(hidden)" might be clearer for empty state.

♻️ Optional alternative
-          value={showToken ? token : '*'.repeat(token.length || 8)}
+          value={showToken ? token : (token.length > 0 ? '*'.repeat(token.length) : '(hidden)')}

docs: update README with dashboard display info and remote TUI management
- README: Clarify `,` and `C` (Shift+C) keybindings separately
- RunApp: Add displayStatus and instanceManager to handleKeyboard deps
- Tests: Move remote tests to tests/ directory for CI coverage counting
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/tui/components/RunApp.tsx (1)

1425-1466: Wrap switch case declarations in a block scope.

The const isPlus and const isMinus declarations inside the switch case are accessible to other cases due to JavaScript's hoisting behaviour. The static analysis tool correctly flags this.

🔧 Suggested fix
         case '+':
         case '=':
         case '-':
-        case '_':
+        case '_': {
           // Add/remove 10 iterations: +/= add, -/_ remove
           const isPlus = key.name === '+' || key.name === '=';
           const isMinus = key.name === '-' || key.name === '_';
           const effectiveStatus = isViewingRemote ? displayStatus : status;
           if ((isPlus || isMinus) &&
               (effectiveStatus === 'ready' || effectiveStatus === 'running' || effectiveStatus === 'executing' || effectiveStatus === 'paused' || effectiveStatus === 'stopped' || effectiveStatus === 'idle' || effectiveStatus === 'complete')) {
             // ... rest of the case body
           }
           break;
+        }
🤖 Fix all issues with AI agents
In `@README.md`:
- Around line 374-375: Update the "Edit Remote (`e` key)" description by
inserting a comma in the sentence "You can change any field including the
alias." so it reads "You can change any field, including the alias." — edit the
README text under the Edit Remote section (the line containing "You can change
any field including the alias.") to add the comma for clarity.

In `@src/tui/components/RunApp.tsx`:
- Around line 1685-1698: The promise returned by getRemote(...) used in the 'e'
and 'x' key handlers can reject and currently has no .catch(), causing unhandled
promise rejections; update both call sites that invoke getRemote(tab.alias) (the
handlers that call setEditingRemote, setRemoteManagementMode,
setShowRemoteManagement) to append a .catch(err => { /* handle error */ }) that
logs the error (or shows a user-facing message) and prevents further state
updates on failure, ensuring failures are gracefully handled instead of bubbling
as unhandled rejections.
- Line 624: The effect currently calls fetchRemoteData() without handling
rejections and several event handlers use .then() chains without .catch, so add
proper error handling: convert the effect call to an async IIFE or await
fetchRemoteData inside a try/catch and call setError/state and
processLogger.error (or console.error) on failure, and for each event handler
that uses promise.then(...) (the handlers with the .then() chains referenced in
the review) add .catch(...) to log the error and update error UI/state or
refactor the chain to async/await with try/catch to ensure all rejections from
fetchRemoteData and subsequent async calls are handled. Ensure you modify the
fetchRemoteData invocation and the event handlers' promise chains accordingly so
they consistently surface and log errors instead of allowing unhandled
rejections.
🧹 Nitpick comments (5)
src/tui/components/RunApp.tsx (1)

2555-2575: Consider the non-atomic nature of alias change operations.

When changing a remote's alias, the code first removes the old config and then adds the new one. If addRemote fails after removeRemote succeeds, the remote configuration is lost. While unlikely, this could leave the config in an inconsistent state.

Consider either:

  1. Adding the new config first, then removing the old one (reverse order)
  2. Adding a rollback mechanism if the add fails
  3. Documenting this as a known limitation
♻️ Optional: Safer ordering
           if (editingRemote && editingRemote.alias !== data.alias) {
+            // Add new config first to avoid data loss if it fails
+            const result = await addRemote(data.alias, data.host, data.port, data.token);
+            if (!result.success) {
+              throw new Error(result.error || 'Failed to add remote');
+            }
             // Remove old config
             await removeRemote(editingRemote.alias);
             // Remove old tab
             instanceManager.removeTab(editingRemote.alias);
-            // Add new config
-            const result = await addRemote(data.alias, data.host, data.port, data.token);
-            if (!result.success) {
-              throw new Error(result.error || 'Failed to add remote');
-            }
             // Connect with new alias
             await instanceManager.addAndConnectRemote(data.alias, data.host, data.port, data.token);
tests/remote/remote.test.ts (4)

347-388: Consider improving or removing this low-value test.

This test merely verifies that an array of hardcoded string literals are non-empty strings, which provides negligible coverage. The comment correctly notes that actual server testing requires Bun.serve.

Consider either:

  1. Removing this placeholder test entirely
  2. Replacing it with an integration test that spins up a real server
  3. At minimum, importing the message type literals from the actual source to catch typos

866-867: Consider using deterministic waiting instead of setTimeout.

Using setTimeout(resolve, 10) for synchronisation is fragile and can lead to flaky tests, especially under CI load. Consider:

  • Using vi.useFakeTimers() / vi.advanceTimersByTime() (or Bun equivalents)
  • Awaiting specific events or state changes
  • Using a utility like waitFor that polls for a condition

This pattern appears at lines 867, 901, 929, and 1149.


843-844: Silent error swallowing may mask test failures.

The .catch(() => {}) pattern silently ignores all errors, which can hide legitimate failures. Consider either:

  • Explicitly expecting specific error types: .catch(e => expect(e).toBeInstanceOf(ExpectedError))
  • Using a more complete mock that doesn't throw unexpectedly
  • Documenting which specific errors are expected and ignored

This pattern recurs throughout the mocked WebSocket tests.


990-1004: Consider using expect(...).resolves for cleaner async assertions.

The try/catch pattern is verbose. Consider:

await expect(manager.refresh()).resolves.not.toThrow();

Or simply:

// If refresh() resolves without throwing, this passes
await manager.refresh();

subsy added 2 commits January 19, 2026 23:41
- token.test.ts: 19 tests for token generation, expiration, validation
- audit.test.ts: 20 tests for AuditLogger class (99% line coverage)

Improves patch coverage for PR diff
- README: Add comma in "You can change any field, including the alias."
- RunApp: Add .catch() to fetchRemoteData() call in useEffect
- RunApp: Add .catch() to getRemoteState/getRemoteTasks .then() chains
- RunApp: Add .catch() to getRemote() calls in 'e' and 'x' key handlers
@subsy subsy merged commit 5b84ecd into main Jan 19, 2026
8 checks passed
@subsy subsy deleted the feat/remotes branch January 19, 2026 23:45
sakaman pushed a commit to sakaman/ralph-tui that referenced this pull request Feb 15, 2026
feat: Remote Instance Monitoring and Control
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

Comments