Skip to content

feat: Smart Remote Control (Approvals & Action Timeline)#10

Merged
alexchaomander merged 10 commits intomainfrom
feat/smart-remote-control
Apr 9, 2026
Merged

feat: Smart Remote Control (Approvals & Action Timeline)#10
alexchaomander merged 10 commits intomainfrom
feat/smart-remote-control

Conversation

@alexchaomander
Copy link
Copy Markdown
Owner

@alexchaomander alexchaomander commented Mar 31, 2026

Summary

This PR implements the Smart Remote Control layer for CloudCode, transforming it from a passive terminal mirror into an active agent management platform. Inspired by specialized tools like claude-watch, these features are built to be agent-agnostic, working with any CLI tool that uses standard terminal conventions.

Key Features

  • Heuristics Engine (Backend): A new semantic parsing engine that runs a headless xterm instance per session to track the visual state of the terminal. It detects interactive prompts and tool-use boundaries with high precision.
  • Smart Approval Modals (Mobile-First): Automatically detects [y/n] and 'Press Enter' prompts. Instead of opening the mobile keyboard, a native UI card slides up with massive, tap-friendly buttons to Approve or Deny.
  • Action Timeline: Intercepts agent tool-use events (like Claude's tool boxes or generic shell commands) and renders them as premium, collapsible cards in a new Timeline tab. This provides a high-signal Task Feed that is easy to skim on the go.
  • Resource Management: Full cleanup logic to ensure headless terminal instances are disposed of when sessions end.

Technical Details

  • Backend: backend/src/terminal/heuristics.ts contains the core logic.
  • Frontend Hook: useTerminal.ts now synchronizes prompt and action states in real-time.
  • UI Components: ActionTimeline.tsx for the card feed and updated Terminal.tsx for the native overlay.

Test Plan

  • Backend tests pass (npm run test)
  • Frontend builds cleanly (npm run build)
  • Verified resource cleanup on WebSocket close.
  • Verified regex patterns against Claude and standard Bash prompts.

@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a 'Smart Remote Control' feature, which includes a heuristic engine to detect agent activity and transform it into a task timeline, along with interactive approval modals for terminal prompts. The implementation adds a HeuristicsEngine on the backend to parse PTY data and corresponding frontend components to display the timeline and handle prompts. Feedback focuses on improving the robustness of the heuristic detection, specifically addressing issues where tool labels changing mid-execution could cause orphaned timeline entries, ensuring multi-byte characters are handled correctly in the terminal buffer, and making shell command detection more flexible for varied prompt environments.

Comment on lines +150 to +159
if (!this.activeAction || this.activeAction.label !== label || this.activeAction.status !== 'running') {
this.activeAction = {
id: nanoid(8),
type,
label,
status: 'running',
startTime: new Date().toISOString()
};
return this.activeAction;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

This logic creates a new action with a new ID whenever the label changes (e.g., when the 'Working...' placeholder is replaced by the actual command in a subsequent chunk). This results in orphaned 'running' entries in the UI timeline that never complete. Instead, the existing activeAction should be updated if it is already running.

      if (this.activeAction && this.activeAction.status === 'running') {
        if (this.activeAction.label !== label) {
          this.activeAction.label = label;
          this.activeAction.type = type;
          return this.activeAction;
        }
        return null;
      }

      this.activeAction = {
        id: nanoid(8),
        type,
        label,
        status: 'running',
        startTime: new Date().toISOString()
      };
      return this.activeAction;

Comment on lines +52 to +53
const chunk = Buffer.from(chunkBase64, 'base64').toString('utf8');
this.term.write(chunk);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Converting raw PTY data to a UTF-8 string before writing to xterm can lead to corrupted output if a multi-byte character is split across chunks. Additionally, this.term should be checked for nullity to avoid potential crashes if data arrives after the engine has been disposed (e.g., during WebSocket closure).

Suggested change
const chunk = Buffer.from(chunkBase64, 'base64').toString('utf8');
this.term.write(chunk);
if (!this.term) return {};
const chunk = Buffer.from(chunkBase64, 'base64');
this.term.write(chunk);

Comment on lines +164 to +172
const lastLine = lines[lines.length - 1] || '';
// Looks for └──────────┘ or ╰──────────╯
if (lastLine.match(/[└╰]─+┘/)) {
this.activeAction.status = 'completed';
this.activeAction.endTime = new Date().toISOString();
const completedAction = { ...this.activeAction };
this.activeAction = null;
return completedAction;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Checking only the very last line for the completion marker is fragile. If the PTY sends the border followed by a prompt or newline in the same chunk, the marker will be on a previous line and the action will stay in the 'running' state. Scanning the last few lines is more robust.

Suggested change
const lastLine = lines[lines.length - 1] || '';
// Looks for └──────────┘ or ╰──────────╯
if (lastLine.match(/[]+/)) {
this.activeAction.status = 'completed';
this.activeAction.endTime = new Date().toISOString();
const completedAction = { ...this.activeAction };
this.activeAction = null;
return completedAction;
}
for (let i = lines.length - 1; i >= Math.max(0, lines.length - 3); i--) {
if (lines[i].match(/[]+/)) {
this.activeAction.status = 'completed';
this.activeAction.endTime = new Date().toISOString();
const completedAction = { ...this.activeAction };
this.activeAction = null;
return completedAction;
}
}

if (!this.activeAction) {
for (let i = lines.length - 1; i >= Math.max(0, lines.length - 3); i--) {
const line = lines[i];
const bashMatch = line.match(/^[\$]\s+([a-zA-Z0-9].+)$/);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The regex ^[$]\s+ is too restrictive as it only matches lines starting exactly with a dollar sign. Most shell prompts include user, host, or path information before the prompt character. Consider allowing leading characters to make this fallback more effective across different environments.

Suggested change
const bashMatch = line.match(/^[\$]\s+([a-zA-Z0-9].+)$/);
const bashMatch = line.match(/[$#]\s+([a-zA-Z0-9].+)$/);

- Fix UTF-8 byte splitting: add decodeUtf8WithCarryover() to handle
  incomplete multi-byte sequences at chunk boundaries
- Fix fragile completion detection: scan all lines instead of just the
  last line to detect tool completion markers
- Fix orphaned running actions: update existing action label instead
  of creating duplicate when label changes during execution
- Add null check for disposed terminal in process()
- Expand shell prompt regex to match $, #, >, ❯, λ prompts
- Add stale action timeout: mark actions as 'error' after 5 minutes
  to prevent stuck 'running' states
- Fix lastCompletionCheckIndex bug: track absolute buffer line positions
  instead of local array indices so completion detection is correct
  across successive process() calls as the cursor advances
- Make HeuristicsEngine.process() async so xterm write() completes
  before buffer is scanned; update routes to await the result
- Remove any casts in useTerminal: extend WebSocket message type union
  with promptState and action fields
- Add dismissPrompt() to useTerminal so approval buttons optimistically
  clear the overlay immediately on tap without waiting for the backend
- Replace smoke-only tests with behavioral assertions: prompt detection
  (yesno, enter), tool-use box start/completion, shell command fallback,
  deduplication, and unique action IDs
@alexchaomander alexchaomander force-pushed the feat/smart-remote-control branch from 7e2ab35 to 7d3a0db Compare April 6, 2026 19:41
@alexchaomander
Copy link
Copy Markdown
Owner Author

Review fixes applied

All issues from the code review have been addressed:

1. Commit message violation (hard block) — fixed
Rewrote the fix: resolve heuristics engine issues commit to remove the Claude Code attribution that violated project guidelines.

2. lastCompletionCheckIndex bug — fixed
Replaced the local-array-index tracker with lastCompletionBufferLine that stores absolute xterm buffer line positions. On each process() call, we scan only lines from max(windowStart, lastCompletionBufferLine) to windowEnd, using activeBuffer.getLine(i) directly. This correctly skips already-scanned lines across calls as the cursor advances, rather than re-indexing into a stale local array.

3. Behavioral tests — replaced
All 20 smoke tests (not.toThrow()) replaced with 30 tests that make real assertions: yesno/enter prompt detection with text extraction, Claude tool-use box start/completion, shell command fallback detection, deduplication, and unique action ID assignment. Also discovered write() is async in headless xterm — made process() return Promise<HeuristicsResult> and updated routes to void heuristics.process(dataBase64).then(...). All 48 tests pass.

4. Prompt overlay self-dismiss — fixed
Added dismissPrompt() to useTerminal (optimistically sets promptState.isActive = false) and wired it to all three approval buttons: Approve, Deny, and Continue. The overlay closes immediately on tap; the backend state catches up when the next prompt.state event arrives.

5. any casts — fixed
Extended the WebSocket message type union with promptState?: PromptState and action?: TimelineAction fields. Removed both as any casts.

@alexchaomander alexchaomander merged commit 1de1e02 into main Apr 9, 2026
@alexchaomander alexchaomander deleted the feat/smart-remote-control branch April 9, 2026 00:25
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