Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ Use `/tools` to see the full list at runtime.
| `/explore <query>` | Run a sub-agent exploration loop over the codebase and return a prose summary. The sub-agent uses read-only tools and runs in an isolated context with no shared history from the main session. |
| `/locate <symbol>` | Run a sub-agent symbol lookup and return a `path:line` result. Faster and more targeted than `/explore` for single-symbol lookups. |
| `/safe-mode` | Show current safe mode status |
| `/safe-mode on` | Disable Shell, Git, and Http tool categories to prevent mutations |
| `/safe-mode on` | Block Shell, Git, and Http tools by owning plugin (including those in the Extended bucket) |
| `/safe-mode off` | Restore tool categories to their state before safe mode was enabled |
| `/hitl` | Show current HITL (human-in-the-loop) mode status |
| `/hitl on` | Require y/N approval before each `shell_run`, `shell_run_script`, or `shell_run_background` call |
Expand Down Expand Up @@ -543,7 +543,7 @@ Restriction on Git removed.
- `/tools unrestrict <plugin>` removes a plugin's restriction
- Run `/tools restrict` with no arguments to see which plugin names have capability tags at all (`FileSystem`, `Shell`, `Git`, `Http`, `Json`, `Document`, `Search`, `Changes`, `Scratchpad`, `Chatroom`, `Probe`, `CodeExecution`, `Decision`, `Graph`) — plugins without fine-grained tags (`Todo`, `SubAgent`, MCP servers, …) can only be turned on or off via `/tools disable`/`/tools enable`, not restricted by tag

**Restricting reaches further than disabling a category.** Filtering is done per-tool by which plugin actually owns it, not by which REPL tool-category dictionary key currently holds it. That distinction matters once `--plugins Extended` is enabled: `git_push` lives in the `Extended` category, not `Git`, so `/tools restrict Git read` still removes it, while `/safe-mode on` — which only disables the `Shell`, `Git`, and `Http` category keys — does not touch `Extended` at all and leaves `git_push` (and any other Extended-bucket Shell/Git/Http tool) callable. If you need a hard guarantee with `Extended` enabled, restrict the plugin by tag rather than relying on `/safe-mode` alone.
**Owning-plugin filtering reaches across category buckets.** Both `/tools restrict` and `/safe-mode` filter per-tool by which plugin actually owns the tool (`PluginCapabilityMap.GetPlugin`), not only by which REPL tool-category dictionary key currently holds it. That distinction matters once `--plugins Extended` is enabled: `git_push` and `shell_run_background` live in the `Extended` category, not `Git`/`Shell`, but both commands still block them. `/safe-mode` leaves FileSystem-owned Extended tools (e.g. `delete_file`) alone; use `/tools restrict FileSystem …` when you need that lock too.

**Input and line editing**

Expand Down
8 changes: 8 additions & 0 deletions docs/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,14 @@ The skill scaffolds `.fuseraft/knowledge/` via `fuseraft init`, builds the repos

---

### `repl-tmux-driver`

Drives an interactive `fuseraft repl` session from outside via tmux, for live-testing REPL changes against a real model instead of relying on unit tests alone. Triggers when the user wants to dogfood the REPL agent on a real task, reproduce a REPL bug interactively, or verify `/safe-mode`, `/tools`, `/hitl`, or similar mode toggles against real agent-visible behavior.

The skill covers launching the REPL in a detached tmux session, injecting single- or multi-line input (via `/paste` plus `tmux load-buffer`/`paste-buffer` for anything with embedded newlines), polling for the idle prompt with a wait loop instead of blind sleeps, capturing pane output to a file for review, and cleaning up with `/exit` so session-end bookkeeping runs.

---

## Cross-session handoff: `/compact`

To pass context from the current REPL session to a new one, use the `/compact` command. `/compact` generates a concise summary of what was worked on, key decisions, current state, and what comes next; it then replaces the conversation history with that summary so the session can continue with a clean context window.
Expand Down
119 changes: 119 additions & 0 deletions skills/repl-tmux-driver/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
---
name: repl-tmux-driver
description: Drive an interactive `fuseraft repl` session from outside via tmux - inject single- or multi-line input, poll for the agent to finish working, and capture output for review. Trigger when the user wants to live-test a REPL change, dogfood the REPL agent on a real task, or verify /safe-mode, /tools, /hitl, or other REPL command behavior against a real model rather than only unit tests.
compatibility: Requires tmux and a built fuseraft binary (./build.sh --target=Build)
---

# REPL Tmux Driver

Drive `fuseraft repl` as an interactive subprocess through tmux so you can feed it a real task, watch it use real tools against a real model, and verify the result - rather than relying on unit tests alone.

## When to Use

Use this skill when:
- Live-testing a REPL behavior change (a new `/command`, a tool-gating fix, a prompt change) against a real model, not just `ReplCommands.HandleAsync` unit tests
- Dogfooding: having the REPL agent itself perform a real task on this repo, then reviewing its diff
- Reproducing a REPL bug report interactively to confirm root cause or confirm a fix
- Verifying `/tools`, `/safe-mode`, `/hitl`, `/adversarial`, or similar mode toggles actually change agent-visible tool surface, not just internal state

Do **not** use this skill for:
- Testing orchestration (`fuseraft run`) sessions - those are non-interactive and can be scripted directly, no tmux needed
- Anything a plain unit test in `tests/FuseraftCli.Tests` already covers - reach for tmux only when a real model round-trip matters
- The VS Code webview bridge (`--vscode`) - that speaks a JSON protocol over stdio, not the human-readable prompt this skill polls for

## Workflow

### Step 1: Build and confirm the model is reachable

```bash
./build.sh --target=Build
./src/bin/Release/net10.0/fuseraft models # confirms provider/API key work; marks "<model> <- current"
```

Always rebuild before a live test of a source change - a stale binary silently tests the old behavior.

### Step 2: Launch the REPL in a dedicated tmux session

```bash
tmux kill-session -t <name> 2>/dev/null # safe no-op if it doesn't already exist
tmux new-session -d -s <name> -x 220 -y 50 -c <repo-root>
tmux send-keys -t <name> "./src/bin/Release/net10.0/fuseraft repl [--plugins Extended] [--model ...]" Enter
```

Wait a few seconds, then confirm the banner and prompt appeared:

```bash
tmux capture-pane -t <name> -p | tail -20
```

Pick flags to match what's under test - e.g. `--plugins Extended` to exercise the Extended tool bucket, `--no-tools` for a prompt-only session, `--resume <id>` to continue a prior one.

### Step 3: Send input

**Single-line message:** send it directly.

```bash
tmux send-keys -t <name> "your message here" Enter
```

**Multi-line / multi-paragraph task:** do not pass a string containing newlines straight to `send-keys` - each embedded newline submits early as its own command. Instead write the task to a file and use the REPL's `/paste` mode with `tmux load-buffer`/`paste-buffer`:

```bash
tmux send-keys -t <name> "/paste" Enter
tmux load-buffer -b task_buf /path/to/task.txt
tmux paste-buffer -b task_buf -t <name>
tmux send-keys -t <name> Enter
tmux send-keys -t <name> ".done" Enter
```

Write the task file with enough context that the agent doesn't have to guess: name the relevant source files and any existing pattern to follow, state the constraints, and ask it to build and run the test suite before reporting back. Describe the problem and point at precedent - don't hand it a finished diff to transcribe; a well-scoped real task is what makes this a genuine test of the agent, not a typing exercise.

### Step 4: Wait for it to finish - don't blind-sleep

The REPL shows a spinner (`thinking...`, or `<verb>... <tool_name> (Ns)`) while working and drops back to a bare numbered prompt (`1>`, or `[safe] 1>` under safe mode) once idle. Poll for that state instead of guessing a sleep duration. Use a proper wait primitive (a Monitor until-loop, or a backgrounded `until` loop) rather than a chain of blind `sleep`s:

```bash
until tmux capture-pane -t <name> -p | tail -6 | grep -qE '^(\[[a-z-]+\] )?[0-9]+> *$'; do
sleep 5
done
```

Size the timeout to the task - a multi-file fix plus a full test run can take several minutes.

### Step 5: Capture and review the result

`tmux capture-pane -p` piped straight into some shell tools can come back looking empty (control-character noise trips naive output handling). Redirect to a file and read that instead of relying on inline capture:

```bash
tmux capture-pane -t <name> -p -S -400 > /path/to/scratch/output.txt
```

Then read the file directly. Treat the agent's own summary as a claim, not a fact, and verify independently:
- `git diff` (not just `--stat`) for every file it touched
- Rebuild and rerun the real test suite yourself: `./build.sh --target=Build && ./build.sh --target=Test`
- If the change affects REPL-visible behavior, drive a **second**, fresh tmux session by hand to exercise the exact before/after (e.g. run `/tools` before and after toggling the mode that changed) rather than trusting that unit tests alone prove the live behavior

### Step 6: Clean up

End the session with `/exit` rather than just killing the pane, so session-end bookkeeping (memory extraction, final event log flush) runs:

```bash
tmux send-keys -t <name> "/exit" Enter
sleep 2
tmux kill-session -t <name> 2>/dev/null
```

Note the "Resume with: fuseraft --resume <id>" line if the same session might need to continue later.

## Gotchas

- **Stale binary.** Rebuild before every live-test session - a REPL launched from an old binary silently tests old behavior and any "fix confirmed" result is worthless.
- **`tmux capture-pane` looking empty.** Redirect to a file and read the file rather than trusting a tool's inline stdout capture of the raw pane dump.
- **Sandboxed tmux instability.** In some sandboxed environments a long-lived tmux pane's underlying process can be silently killed and restarted, which looks identical to an application crash or an unexpected `/clear`. If a session seems to have reset without explanation, check the pane's shell PID before concluding it's a fuseraft bug.
- **Don't chain blind sleeps to poll.** Prefer an until-loop that checks the actual prompt state over guessing durations - guesses are either too short (you read a mid-turn state) or too long (you waste the wait).
- **`/paste` needs the literal `.done`** on its own line (or Ctrl+D) to exit paste mode - a plain trailing newline is not enough and leaves the REPL waiting for more input.

## References

- Full REPL command reference: `docs/cli-reference.md`
- Live list of REPL commands: run `/help` inside the session
38 changes: 26 additions & 12 deletions src/Cli/Commands/Repl/ReplCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,22 @@ private static readonly (string EnvVar, string ModelId)[] AutoDetectOrder =
"git_add", "git_commit", "git_stash_list",
};

// ReplSessionPlugin split in two: compact_context/get_context_status are load-bearing for
// the main loop's own context-budget self-management, so they stay in the default tool set.
// current/list/read_event_log/read_log let the model enumerate and read a *different*
// session's full event log by ID/prefix match — real cross-session data exposure with no
// turn-to-turn value for the primary agent, so they're withheld from the default set and
// handed only to /assist's diagnose loop instead (see SubAgentPlugin's diagnosticTools).
private static readonly HashSet<string> CoreSessionTools = new(StringComparer.OrdinalIgnoreCase)
{
"repl_session_compact_context", "repl_session_get_context_status",
};

private static readonly HashSet<string> SessionDiagnosticTools = new(StringComparer.OrdinalIgnoreCase)
{
"repl_session_current", "repl_session_list", "repl_session_read_event_log", "repl_session_read_log",
};

protected override async Task<int> ExecuteAsync(
CommandContext context, ReplSettings settings, CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -127,7 +143,8 @@ protected override async Task<int> ExecuteAsync(
}
else if (!string.IsNullOrEmpty(legacyKey))
{
userCfg!.ApiKey = legacyKey;
userCfg ??= new UserConfig();
userCfg.ApiKey = legacyKey;
if (await KeyStorePersistence.TryStoreAsync(keyStore, legacyKey))
AnsiConsole.MarkupLine($"[dim]API key migrated to {Markup.Escape(keyStore.StoreName)}.[/]");
UserConfigStore.Save(userCfg);
Expand Down Expand Up @@ -212,6 +229,7 @@ protected override async Task<int> ExecuteAsync(
IReadOnlyList<AgentSkill> discoveredSkills = [];
string? skillsCatalog = null;
List<AIFunction>? explorerTools = null;
List<AIFunction> sessionDiagnosticTools = [];
TodoPlugin? todoPlugin = null;
FileSystemPlugin? fsPluginForCategory = null;
McpSessionManager? mcpManager = null;
Expand Down Expand Up @@ -300,7 +318,9 @@ protected override async Task<int> ExecuteAsync(
if (!settings.NoTools)
{
replSessionPlugin = new ReplSessionPlugin(sessionId, startedAt, modelId, cwd);
toolsByCategory["Session"] = PluginRegistry.GetFunctionsFromObject(replSessionPlugin).ToList();
var sessionFunctions = PluginRegistry.GetFunctionsFromObject(replSessionPlugin).ToList();
toolsByCategory["Session"] = sessionFunctions.Where(f => CoreSessionTools.Contains(f.Name)).ToList();
sessionDiagnosticTools = sessionFunctions.Where(f => SessionDiagnosticTools.Contains(f.Name)).ToList();

var enabled = settings.EnabledPlugins;
var slug = FuseraftPaths.ProjectSlug(cwd);
Expand Down Expand Up @@ -391,9 +411,10 @@ protected override async Task<int> ExecuteAsync(
subAgent = new SubAgentPlugin(
ReplFactory.BuildClient(modelConfig, factory, explorerTools.Count > 0, adaptiveTrimTracker, emitter),
explorerTools,
eventEmitter: emitter,
parentAgentName: "repl",
delegateTools: delegateTools);
eventEmitter: emitter,
parentAgentName: "repl",
delegateTools: delegateTools,
diagnosticTools: sessionDiagnosticTools);
toolsByCategory["SubAgent"] = PluginRegistry.GetFunctionsFromObject(subAgent).ToList();
}

Expand Down Expand Up @@ -477,13 +498,6 @@ protected override async Task<int> ExecuteAsync(
}
}

if (toolsByCategory.TryGetValue("FileSystem", out _))
{
var fsResettable = toolsByCategory["FileSystem"]
.Select(f => f.UnderlyingMethod?.DeclaringType)
.FirstOrDefault();
}

if (discoveredSkills.Count > 0)
ctx.LineReader.SetSkillSlugs([.. discoveredSkills.Select(s => s.Frontmatter.Name)]);

Expand Down
4 changes: 3 additions & 1 deletion src/Cli/Commands/Repl/ReplCommands.Agents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ private static async Task<CommandResult> CmdAssistAsync(
: Task.CompletedTask;
try
{
var correction = await ctx.SubAgent.DiagnoseAsync(ctx.History, cancellationToken);
var (correction, inputTok, outputTok) = await ctx.SubAgent.DiagnoseAsync(ctx.History, cancellationToken);
ctx.CumulativeInputTokens += inputTok ?? 0;
ctx.CumulativeOutputTokens += outputTok ?? 0;
if (spinCts is not null) { spinCts.Cancel(); await spinTask; ReplConsole.ClearSpinnerLine(); }

if (correction is null)
Expand Down
38 changes: 37 additions & 1 deletion src/Cli/Commands/Repl/ReplCommands.Mcp.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text;
using Microsoft.Extensions.AI;
using Spectre.Console;
using fuseraft.Core.Models.Config;
Expand Down Expand Up @@ -95,7 +96,7 @@ private static async Task<CommandResult> CmdMcpAddAsync(
Name = name,
Transport = "stdio",
Command = command.Trim(),
Args = argsLine.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList(),
Args = SplitStdioArgs(argsLine),
};
}
else
Expand Down Expand Up @@ -147,6 +148,41 @@ private static async Task<CommandResult> CmdMcpAddAsync(
return CommandResult.Continue;
}

// Quote-aware split for the stdio "Arguments" prompt — a bare space.Split would break an
// argument value containing a space (e.g. a path) into multiple Args entries.
private static List<string> SplitStdioArgs(string argsLine)
{
var result = new List<string>();
var current = new StringBuilder();
char? quote = null;
var inToken = false;

foreach (var c in argsLine)
{
if (quote is not null)
{
if (c == quote) quote = null;
else current.Append(c);
continue;
}
if (c is '"' or '\'')
{
quote = c;
inToken = true;
continue;
}
if (char.IsWhiteSpace(c))
{
if (inToken) { result.Add(current.ToString()); current.Clear(); inToken = false; }
continue;
}
current.Append(c);
inToken = true;
}
if (inToken) result.Add(current.ToString());
return result;
}

private static async Task<CommandResult> CmdMcpRemoveAsync(ReplSessionContext ctx, string name)
{
name = name.Trim();
Expand Down
3 changes: 3 additions & 0 deletions src/Cli/Commands/Repl/ReplCommands.Planning.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ private static async Task<CommandResult> CmdPlanAsync(ReplSessionContext ctx, st
$"Focus on intentful actions only — no defensive steps like verifying CWD or reading files back." +
$"\n\nTask: {arg}";

ctx.CurrentPlanRequest = arg;
await ctx.Emitter.EmitAsync(EventTypes.Command, payload: new { command = "/plan", task = arg });
return CommandResult.Send(planPrompt, capturePlan: true);
}
Expand Down Expand Up @@ -166,6 +167,7 @@ private static async Task<CommandResult> CmdCompactAsync(

// /compact resets the displayed turn counter so status lines restart from 1.
ctx.TurnIndex = 0;
ctx.LastExtractedTurnIndex = -1;

if (ctx.JsonMode)
ReplJsonBridge.Emit(new { type = "compacted" });
Expand Down Expand Up @@ -220,6 +222,7 @@ private static async Task<CommandResult> CmdCompactAsync(
ctx.History.Add(new ChatMessage(ChatRole.User, $"[Compacted context from previous session]\n\n{summary}"));

ctx.PrevTurnTokenEstimate = 0;
ctx.PrevCtxEstimate = 0;
ctx.TurnTokenDeltas.Clear();
ctx.ContextWarningShown = false;
ctx.ResetPlanState();
Expand Down
2 changes: 2 additions & 0 deletions src/Cli/Commands/Repl/ReplCommands.Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ private static async Task<CommandResult> CmdClearAsync(ReplSessionContext ctx)
ctx.History.Clear();
if (sys is not null) ctx.History.Add(sys);
ctx.TurnIndex = 0;
ctx.LastExtractedTurnIndex = -1;
ctx.PrevTurnTokenEstimate = 0;
ctx.TurnTokenDeltas.Clear();
ctx.ContextWarningShown = false;
Expand Down Expand Up @@ -332,6 +333,7 @@ private static async Task<CommandResult> CmdRewindAsync(
ctx.History.Clear();
ctx.History.AddRange(kept);
ctx.TurnIndex = targetTurn;
ctx.LastExtractedTurnIndex = -1;
ctx.PrevTurnTokenEstimate = 0;
ctx.PrevCtxEstimate = 0;
if (ctx.TurnTokenDeltas.Count > targetTurn)
Expand Down
Loading