From 05e1cec6f52826be82bfbd38631dcaf0a405e6ba Mon Sep 17 00:00:00 2001 From: Scott Stauffer Date: Sun, 6 Sep 2026 18:44:21 -0500 Subject: [PATCH 1/7] fix(repl): close eleven engineering gaps found in a REPL audit pass - Crash: legacy-key migration dereferenced a null UserConfig when ~/.fuseraft/config was missing but an old .key file survived. - Turn-index corruption: mutation/critic/todo correction turns recursed into ExecuteAsync before the outer turn's own bookkeeping ran, inflating ctx.TurnIndex and mislabeling TurnEnd/message_end events. Corrections now run after the turn they follow has fully closed out. - Silent memory loss: LastExtractedTurnIndex wasn't reset by /clear, /rewind, or /compact, so exit-time memory extraction could compare against a stale index and skip a whole post-reset conversation. - Safe-mode desync: /tools enable on a Shell/Git/Http category left SafeMode=true while the category was actually back on, so a later /safe-mode on would no-op instead of re-disabling it. - JSON-mode contract break: /execute's critic-review branch wrote raw ANSI to stdout unconditionally; now gated on JsonMode, with the rejection reason carried in the StepHalted event payload instead. - /tools restrict silently blocked 100% of a plugin's tools when given a tag that plugin doesn't use (e.g. "Http write"); now warns. - /assist never recorded token usage, unlike /explore, /delegate, and /locate; DiagnoseAsync now returns usage like its siblings. - /compact didn't reset PrevCtxEstimate, so the next /context showed a confusing negative delta against pre-compaction usage. - A plan with duplicate step numbers silently dropped a step with no warning; now flagged at plan-capture time. - Removed a dead, no-op reflection pass left over from a refactor. - /mcp add split stdio arguments on bare spaces with no quoting, breaking any argument containing a space; added a quote-aware split. --- src/Cli/Commands/Repl/ReplCommand.cs | 10 +---- src/Cli/Commands/Repl/ReplCommands.Agents.cs | 4 +- src/Cli/Commands/Repl/ReplCommands.Mcp.cs | 38 ++++++++++++++++++- .../Commands/Repl/ReplCommands.Planning.cs | 2 + src/Cli/Commands/Repl/ReplCommands.Session.cs | 2 + src/Cli/Commands/Repl/ReplCommands.Tools.cs | 32 +++++++++++++++- src/Cli/Commands/Repl/ReplTurn.cs | 25 +++++++----- src/Cli/Commands/Repl/ReplTurnOutcome.cs | 26 +++++++++++-- .../Plugins/PluginCapabilityMap.cs | 12 ++++++ src/Infrastructure/Plugins/SubAgentPlugin.cs | 14 ++++--- 10 files changed, 135 insertions(+), 30 deletions(-) diff --git a/src/Cli/Commands/Repl/ReplCommand.cs b/src/Cli/Commands/Repl/ReplCommand.cs index ae2c0d4c..bc231a73 100644 --- a/src/Cli/Commands/Repl/ReplCommand.cs +++ b/src/Cli/Commands/Repl/ReplCommand.cs @@ -127,7 +127,8 @@ protected override async Task 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); @@ -477,13 +478,6 @@ protected override async Task 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)]); diff --git a/src/Cli/Commands/Repl/ReplCommands.Agents.cs b/src/Cli/Commands/Repl/ReplCommands.Agents.cs index ee72a9d9..330034d8 100644 --- a/src/Cli/Commands/Repl/ReplCommands.Agents.cs +++ b/src/Cli/Commands/Repl/ReplCommands.Agents.cs @@ -29,7 +29,9 @@ private static async Task 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) diff --git a/src/Cli/Commands/Repl/ReplCommands.Mcp.cs b/src/Cli/Commands/Repl/ReplCommands.Mcp.cs index 8c976a1e..afd9dc36 100644 --- a/src/Cli/Commands/Repl/ReplCommands.Mcp.cs +++ b/src/Cli/Commands/Repl/ReplCommands.Mcp.cs @@ -1,3 +1,4 @@ +using System.Text; using Microsoft.Extensions.AI; using Spectre.Console; using fuseraft.Core.Models.Config; @@ -95,7 +96,7 @@ private static async Task CmdMcpAddAsync( Name = name, Transport = "stdio", Command = command.Trim(), - Args = argsLine.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList(), + Args = SplitStdioArgs(argsLine), }; } else @@ -147,6 +148,41 @@ private static async Task 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 SplitStdioArgs(string argsLine) + { + var result = new List(); + 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 CmdMcpRemoveAsync(ReplSessionContext ctx, string name) { name = name.Trim(); diff --git a/src/Cli/Commands/Repl/ReplCommands.Planning.cs b/src/Cli/Commands/Repl/ReplCommands.Planning.cs index 36928c2b..4035338e 100644 --- a/src/Cli/Commands/Repl/ReplCommands.Planning.cs +++ b/src/Cli/Commands/Repl/ReplCommands.Planning.cs @@ -166,6 +166,7 @@ private static async Task 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" }); @@ -220,6 +221,7 @@ private static async Task 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(); diff --git a/src/Cli/Commands/Repl/ReplCommands.Session.cs b/src/Cli/Commands/Repl/ReplCommands.Session.cs index 6d04c5cf..eceb03e8 100644 --- a/src/Cli/Commands/Repl/ReplCommands.Session.cs +++ b/src/Cli/Commands/Repl/ReplCommands.Session.cs @@ -18,6 +18,7 @@ private static async Task 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; @@ -332,6 +333,7 @@ private static async Task 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) diff --git a/src/Cli/Commands/Repl/ReplCommands.Tools.cs b/src/Cli/Commands/Repl/ReplCommands.Tools.cs index 195844d1..bb2e57ce 100644 --- a/src/Cli/Commands/Repl/ReplCommands.Tools.cs +++ b/src/Cli/Commands/Repl/ReplCommands.Tools.cs @@ -8,6 +8,8 @@ namespace fuseraft.Cli.Commands.Repl; internal static partial class ReplCommands { + private static readonly string[] SafeModeCategories = { "Shell", "Git", "Http" }; + // ------------------------------------------------------------------------- // /tools // ------------------------------------------------------------------------- @@ -75,6 +77,17 @@ private static async Task CmdToolsAsync(ReplSessionContext ctx, s ctx.DisabledCategories.Remove(match); ctx.ChatOptions = ctx.BuildChatOptions(); AnsiConsole.MarkupLine($"[dim]{Markup.Escape(match)} tools enabled.[/]"); + if (ctx.SafeMode && SafeModeCategories.Contains(match, StringComparer.OrdinalIgnoreCase)) + { + // Manually re-enabling a category safe mode is managing breaks the + // "safe mode on == Shell/Git/Http disabled" guarantee — drop the flag + // so it doesn't keep claiming a protection that's no longer in effect, + // and so a later `/safe-mode on` actually re-disables things instead of + // no-oping on "already on". + ctx.SafeMode = false; + ctx.PreSafeDisabled = null; + AnsiConsole.MarkupLine("[yellow]Safe mode disengaged[/] [dim](re-enabled a category it was managing).[/]"); + } await ctx.Emitter.EmitAsync(EventTypes.Command, payload: new { command = "/tools enable", category = match }); } } @@ -153,9 +166,26 @@ private static async Task CmdToolsRestrictAsync(ReplSessionContext ctx, string r ctx.ChatOptions = ctx.BuildChatOptions(); if (!PluginCapabilityMap.KnownPlugins.Contains(plugin)) + { AnsiConsole.MarkupLine( $"[yellow]Warning:[/] '{Markup.Escape(plugin)}' has no capability-tagged tools — " + $"this restriction won't match anything. Known plugins: {string.Join(", ", PluginCapabilityMap.KnownPlugins.OrderBy(p => p))}"); + } + else + { + var known = PluginCapabilityMap.GetCapabilitiesForPlugin(plugin); + var matched = tags.Where(t => known.Contains(t)).ToList(); + if (matched.Count == 0) + AnsiConsole.MarkupLine( + $"[yellow]Warning:[/] none of [{Markup.Escape(string.Join(", ", tags))}] are tags {Markup.Escape(plugin)} uses — " + + $"this blocks ALL of {Markup.Escape(plugin)}'s tools. {Markup.Escape(plugin)}'s tags are: " + + $"{string.Join(", ", known.OrderBy(t => t))}."); + else if (matched.Count < tags.Count) + AnsiConsole.MarkupLine( + $"[yellow]Warning:[/] {Markup.Escape(plugin)} has no tools tagged " + + $"{string.Join(", ", tags.Except(matched, StringComparer.OrdinalIgnoreCase).Select(Markup.Escape))} — " + + $"{Markup.Escape(plugin)}'s tags are: {string.Join(", ", known.OrderBy(t => t))}."); + } AnsiConsole.MarkupLine($"[dim]Restricted[/] [bold]{Markup.Escape(plugin)}[/] [dim]to:[/] {Markup.Escape(string.Join(", ", tags))}"); await ctx.Emitter.EmitAsync(EventTypes.Command, payload: new { command = "/tools restrict", plugin, tags }); @@ -185,7 +215,7 @@ private static async Task CmdSafeModeAsync(ReplSessionContext ctx else { ctx.PreSafeDisabled = new HashSet(ctx.DisabledCategories, StringComparer.OrdinalIgnoreCase); - foreach (var c in new[] { "Shell", "Git", "Http" }.Where(c => ctx.ToolsByCategory.ContainsKey(c))) + foreach (var c in SafeModeCategories.Where(c => ctx.ToolsByCategory.ContainsKey(c))) ctx.DisabledCategories.Add(c); ctx.ChatOptions = ctx.BuildChatOptions(); ctx.SafeMode = true; diff --git a/src/Cli/Commands/Repl/ReplTurn.cs b/src/Cli/Commands/Repl/ReplTurn.cs index b3a4458b..80e23f26 100644 --- a/src/Cli/Commands/Repl/ReplTurn.cs +++ b/src/Cli/Commands/Repl/ReplTurn.cs @@ -506,15 +506,6 @@ internal static async Task ExecuteAsync( stepPassed = await ReplTurnOutcome.HandleStepResult(ctx, activeStep, stepTotal, toolCallsThisTurn, capturedResults ?? [], hitIterationCap, responseText, cancellationToken); - await TryApplyMutationCorrectionAsync( - ctx, responseText, toolCallsThisTurn, isStepRequest, capturePlan, isCorrectionTurn, cancellationToken); - - await TryApplyCriticReviewAsync( - ctx, input, responseText, toolCallsThisTurn, isStepRequest, capturePlan, isCorrectionTurn, cancellationToken); - - await TryApplyTodoCompletionCorrectionAsync( - ctx, responseText, isStepRequest, capturePlan, isCorrectionTurn, cancellationToken); - var postEst = ctx.EstimateTokens(); if (ctx.PrevTurnTokenEstimate > 0) ctx.TurnTokenDeltas.Add(postEst - ctx.PrevTurnTokenEstimate); @@ -617,6 +608,7 @@ await TryApplyTodoCompletionCorrectionAsync( if (compacted) { ctx.TurnIndex = 0; + ctx.LastExtractedTurnIndex = -1; } else if (!ctx.JsonMode) { @@ -677,6 +669,21 @@ await ctx.Emitter.EmitAsync(EventTypes.HistoryTrimmed, turn: ctx.TurnIndex, ReplJsonBridge.Emit(new { type = "message_end", turnIndex = ctx.TurnIndex, toolCalls = toolCallsThisTurn.ToArray() }); ctx.TurnIndex++; + + // Run only after this turn has fully closed out (index incremented, its own + // TurnEnd/message_end emitted) so a triggered correction becomes a genuinely new + // next turn with its own turn index and events, instead of a nested call whose + // TurnIndex++ and emits would otherwise land inside this turn's own tail and get + // relabeled onto the wrong turn. + await TryApplyMutationCorrectionAsync( + ctx, responseText, toolCallsThisTurn, isStepRequest, capturePlan, isCorrectionTurn, cancellationToken); + + await TryApplyCriticReviewAsync( + ctx, input, responseText, toolCallsThisTurn, isStepRequest, capturePlan, isCorrectionTurn, cancellationToken); + + await TryApplyTodoCompletionCorrectionAsync( + ctx, responseText, isStepRequest, capturePlan, isCorrectionTurn, cancellationToken); + return stepPassed; } diff --git a/src/Cli/Commands/Repl/ReplTurnOutcome.cs b/src/Cli/Commands/Repl/ReplTurnOutcome.cs index 69603f53..c197801b 100644 --- a/src/Cli/Commands/Repl/ReplTurnOutcome.cs +++ b/src/Cli/Commands/Repl/ReplTurnOutcome.cs @@ -18,6 +18,20 @@ internal static void HandlePlanCapture(ReplSessionContext ctx, string responseTe if (TryParsePlan(responseText, out var steps) && steps.Length > 0) { ctx.CurrentPlan = steps; + + var duplicateSteps = steps.GroupBy(s => s.Step).Where(g => g.Count() > 1).Select(g => g.Key).OrderBy(n => n).ToList(); + if (duplicateSteps.Count > 0) + { + // TopologicalSort/execution index steps by number and tolerate collisions + // (last one wins) rather than crashing, so a duplicate silently drops a step + // unless flagged here. + var warning = $"Plan has duplicate step number(s) {string.Join(", ", duplicateSteps)} — only the last step with each number will run."; + if (ctx.JsonMode) + ReplJsonBridge.Emit(new { type = "warning", text = warning }); + else + AnsiConsole.MarkupLine($"[yellow]⚠ {Markup.Escape(warning)}[/]"); + } + _ = ctx.Emitter.EmitAsync(EventTypes.PlanCaptured, turn: ctx.TurnIndex, payload: new { step_count = steps.Length, @@ -71,18 +85,21 @@ internal static async Task HandleStepResult( var stepsLeft = ctx.ExecutionQueue.Count; // When deterministic checks pass and adversarial mode is on, ask the critic. + string? criticReason = null; if (passed && ctx.AdversarialMode && ctx.SubAgent is not null) { - AnsiConsole.Markup("[dim] critic reviewing…[/]"); + if (!ctx.JsonMode) AnsiConsole.Markup("[dim] critic reviewing…[/]"); var (approved, reason) = await ctx.SubAgent.CriticReviewAsync( activeStep.Description, activeStep.Tool, toolCallsThisTurn, responseText, cancellationToken); - Console.Write($"\r{new string(' ', 40)}\r"); + if (!ctx.JsonMode) Console.Write($"\r{new string(' ', 40)}\r"); if (!approved) { passed = false; + criticReason = reason; ctx.RecoveryHint = $"[Critic] Step {activeStep.Step} rejected: {reason}"; - AnsiConsole.MarkupLine( - $"[yellow] ✗ Critic rejected step {activeStep.Step}: {Markup.Escape(reason ?? "no reason given")}[/]"); + if (!ctx.JsonMode) + AnsiConsole.MarkupLine( + $"[yellow] ✗ Critic rejected step {activeStep.Step}: {Markup.Escape(reason ?? "no reason given")}[/]"); } } if (passed) @@ -136,6 +153,7 @@ internal static async Task HandleStepResult( hit_iteration_cap = hitIterationCap, tool_calls = toolCallsThisTurn.ToArray(), verify_output = verifyOutput, + critic_reason = criticReason, }); if (!ctx.JsonMode) { diff --git a/src/Infrastructure/Plugins/PluginCapabilityMap.cs b/src/Infrastructure/Plugins/PluginCapabilityMap.cs index 7dc92f55..19a4fd12 100644 --- a/src/Infrastructure/Plugins/PluginCapabilityMap.cs +++ b/src/Infrastructure/Plugins/PluginCapabilityMap.cs @@ -198,6 +198,18 @@ public static bool IsAllowed(string toolName, IReadOnlyList allowedCapab public static string? GetPlugin(string toolName) => ToolInfo.TryGetValue(toolName, out var info) ? info.Plugin : null; + /// + /// The distinct capability tags actually used by 's tools (e.g. + /// {"get","post","put","patch","delete"} for Http). Used by /tools + /// restrict to catch a tag that doesn't exist for the given plugin — e.g. Http + /// has no read/write tags, so restricting it to one would silently match + /// zero tools and block the plugin entirely rather than the intended subset. + /// + public static IReadOnlySet GetCapabilitiesForPlugin(string plugin) => + new HashSet( + ToolInfo.Values.Where(v => v.Plugin.Equals(plugin, StringComparison.OrdinalIgnoreCase)).Select(v => v.Capability), + StringComparer.OrdinalIgnoreCase); + /// /// Test-only accessor: when has an /// explicit capability entry. Used by a coverage test asserting every built-in plugin diff --git a/src/Infrastructure/Plugins/SubAgentPlugin.cs b/src/Infrastructure/Plugins/SubAgentPlugin.cs index 01577f66..917d9e68 100644 --- a/src/Infrastructure/Plugins/SubAgentPlugin.cs +++ b/src/Infrastructure/Plugins/SubAgentPlugin.cs @@ -181,11 +181,11 @@ public async Task DelegateAsync( // Reads the REPL conversation history, identifies where things are going wrong, and returns // a corrective instruction addressed to the REPL agent for injection as a user message. // Returns null when the diagnoser produces no output or the call fails/times out. - public async Task DiagnoseAsync( + public async Task<(string? Result, int? InputTokens, int? OutputTokens)> DiagnoseAsync( IReadOnlyList history, CancellationToken cancellationToken = default) { - if (chatClient is null) return null; + if (chatClient is null) return (null, null, null); const string diagnosticSystem = "You are a session diagnostician. You will receive a transcript of a conversation " + @@ -223,16 +223,18 @@ public async Task DelegateAsync( cts.CancelAfter(TimeSpan.FromMinutes(2)); try { - var response = await chatClient.GetResponseAsync(messages, options, cts.Token); - var text = (response.Text ?? string.Empty).Trim(); - return string.IsNullOrEmpty(text) ? null : text; + var response = await chatClient.GetResponseAsync(messages, options, cts.Token); + var text = (response.Text ?? string.Empty).Trim(); + var inputTok = (int?)response.Usage?.InputTokenCount; + var outputTok = (int?)response.Usage?.OutputTokenCount; + return (string.IsNullOrEmpty(text) ? null : text, inputTok, outputTok); } catch (Exception ex) { if (eventEmitter is not null) try { await eventEmitter.EmitAsync(EventTypes.SubAgentEnd, agent: parentAgentName, payload: new { outcome = "error", error = ex.Message, mode = "diagnose" }); } catch { } - return null; + return (null, null, null); } } From c11cb930f10f096ed74533ce3837272612fe7d35 Mon Sep 17 00:00:00 2001 From: Scott Stauffer Date: Sun, 6 Sep 2026 19:38:43 -0500 Subject: [PATCH 2/7] refactor(repl): split ReplSessionPlugin between default and /assist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compact_context/get_context_status are load-bearing for the main agent's own context-budget self-management, so they stay default-on. current/list/read_event_log/read_log let the model 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 loop — so they're withheld from the default set and handed only to /assist's diagnose loop, which previously ran with no tools at all. --- src/Cli/Commands/Repl/ReplCommand.cs | 28 +++++++++++++++++--- src/Infrastructure/Plugins/SubAgentPlugin.cs | 17 ++++++++++-- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/Cli/Commands/Repl/ReplCommand.cs b/src/Cli/Commands/Repl/ReplCommand.cs index bc231a73..114ff29c 100644 --- a/src/Cli/Commands/Repl/ReplCommand.cs +++ b/src/Cli/Commands/Repl/ReplCommand.cs @@ -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 CoreSessionTools = new(StringComparer.OrdinalIgnoreCase) + { + "repl_session_compact_context", "repl_session_get_context_status", + }; + + private static readonly HashSet SessionDiagnosticTools = new(StringComparer.OrdinalIgnoreCase) + { + "repl_session_current", "repl_session_list", "repl_session_read_event_log", "repl_session_read_log", + }; + protected override async Task ExecuteAsync( CommandContext context, ReplSettings settings, CancellationToken cancellationToken) { @@ -213,6 +229,7 @@ protected override async Task ExecuteAsync( IReadOnlyList discoveredSkills = []; string? skillsCatalog = null; List? explorerTools = null; + List sessionDiagnosticTools = []; TodoPlugin? todoPlugin = null; FileSystemPlugin? fsPluginForCategory = null; McpSessionManager? mcpManager = null; @@ -301,7 +318,9 @@ protected override async Task 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); @@ -392,9 +411,10 @@ protected override async Task 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(); } diff --git a/src/Infrastructure/Plugins/SubAgentPlugin.cs b/src/Infrastructure/Plugins/SubAgentPlugin.cs index 917d9e68..ea796014 100644 --- a/src/Infrastructure/Plugins/SubAgentPlugin.cs +++ b/src/Infrastructure/Plugins/SubAgentPlugin.cs @@ -49,8 +49,14 @@ public sealed class SubAgentPlugin( string? parentAgentName = null, int maxToolCalls = 0, string? workspaceRoot = null, - IReadOnlyList? delegateTools = null) + IReadOnlyList? delegateTools = null, + IReadOnlyList? diagnosticTools = null) { + // Session-introspection tools (current session metadata, saved-session list, event/log + // file reads) withheld from the REPL agent's own default tool set — they let a caller + // read a *different* session's full event log by ID, real cross-session data exposure + // with no turn-to-turn value for the primary loop — but useful for /assist's diagnosis. + private readonly IReadOnlyList _diagnosticTools = diagnosticTools ?? []; private const double ExploreTimeoutMinutes = 8.0; private const double LocateTimeoutMinutes = 2.0; private const double DelegateTimeoutMinutes = 15.0; @@ -187,7 +193,7 @@ public async Task DelegateAsync( { if (chatClient is null) return (null, null, null); - const string diagnosticSystem = + var diagnosticSystem = "You are a session diagnostician. You will receive a transcript of a conversation " + "between a user and an AI coding assistant that has stalled or gone off track.\n\n" + "Identify the root cause: repeated failures, fabricated tool output, " + @@ -198,6 +204,11 @@ public async Task DelegateAsync( "Be specific and concrete. Reference file paths or symbols where relevant.\n\n" + "Output ONLY the corrective instruction. No preamble, no diagnosis header, " + "no explanation to the user — just the message to inject."; + if (_diagnosticTools.Count > 0) + diagnosticSystem += + "\n\nThe transcript below is truncated. If it doesn't give you enough to go on, " + + "call the available session tools first (e.g. read the event log for the full " + + "tool-call history) before writing the corrective instruction."; const int msgCap = 800; var transcript = new StringBuilder(); @@ -218,6 +229,8 @@ public async Task DelegateAsync( new(ChatRole.User, $"Conversation transcript:\n\n{transcript}"), }; var options = new ChatOptions { MaxOutputTokens = 512 }; + if (_diagnosticTools.Count > 0) + options.Tools = [.. _diagnosticTools]; using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(TimeSpan.FromMinutes(2)); From 4b185b9fa71523f8e75dd2f93b41a54638bfe29a Mon Sep 17 00:00:00 2001 From: Scott Stauffer Date: Sun, 6 Sep 2026 20:13:27 -0500 Subject: [PATCH 3/7] fix(repl): correct stale repl_session_* claim in the system prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The main agent's system prompt told it to use repl_session_* tools to inspect session metadata, list past sessions, or read log files — but the prior ReplSessionPlugin split withheld exactly those tools from the default set, leaving only compact_context/get_context_status. Point the prompt at the two tools it actually still has. --- src/Cli/Commands/Repl/SystemPromptBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cli/Commands/Repl/SystemPromptBuilder.cs b/src/Cli/Commands/Repl/SystemPromptBuilder.cs index fb003e37..36f9039d 100644 --- a/src/Cli/Commands/Repl/SystemPromptBuilder.cs +++ b/src/Cli/Commands/Repl/SystemPromptBuilder.cs @@ -99,7 +99,7 @@ internal SystemPromptBuilder AddSessionInfo( $"Started: {sessionStarted}\n" + $"Snapshot: {snapshotPath}\n" + $"Event log: {FuseraftPaths.ExpandSessionPaths(FuseraftPaths.LocalReplEventsLog, sessionId, FuseraftPaths.ProjectSlug(cwd))}\n" + - $"Use the repl_session_* tools to inspect session metadata, list past sessions, or read log files."); + $"Use repl_session_compact_context to free up context budget and repl_session_get_context_status to check current usage."); } // Orient the agent to the .fuseraft/ layout so it never wastes context From 9142c97514f17501e41c1b2bda09e3797ccfd19a Mon Sep 17 00:00:00 2001 From: Scott Stauffer Date: Sun, 6 Sep 2026 20:13:40 -0500 Subject: [PATCH 4/7] feat(repl): add a scope axis to the adversarial-mode critic review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Judge a third thing alongside correctness/completeness: whether the response is right-sized for what the user actually asked for, not just the plan step's own (possibly drifted) description. Threads the original /plan text through as ctx.CurrentPlanRequest so step reviews have the real ask to compare against, not a paraphrase. Live-tested against grok-4-1-fast-reasoning: an over-scoped response (unrequested extra files, unrelated git commits) is reliably rejected citing the added scope. The first rubric wording also flagged a plain write-then-verify (write_file + read_file) as scope creep, which would have fought the main agent's own required post-write verification step — added an explicit carve-out so confirmation actions aren't penalized. Re-verified 3x after the fix: control approves, over-scope still rejects. --- .../Commands/Repl/ReplCommands.Planning.cs | 1 + src/Cli/Commands/Repl/ReplSessionContext.cs | 5 +++ src/Cli/Commands/Repl/ReplTurn.cs | 2 +- src/Cli/Commands/Repl/ReplTurnOutcome.cs | 3 +- src/Infrastructure/Plugins/SubAgentPlugin.cs | 34 +++++++++++++------ 5 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/Cli/Commands/Repl/ReplCommands.Planning.cs b/src/Cli/Commands/Repl/ReplCommands.Planning.cs index 4035338e..8a63aff7 100644 --- a/src/Cli/Commands/Repl/ReplCommands.Planning.cs +++ b/src/Cli/Commands/Repl/ReplCommands.Planning.cs @@ -43,6 +43,7 @@ private static async Task 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); } diff --git a/src/Cli/Commands/Repl/ReplSessionContext.cs b/src/Cli/Commands/Repl/ReplSessionContext.cs index 19c4c38a..79ea5a8a 100644 --- a/src/Cli/Commands/Repl/ReplSessionContext.cs +++ b/src/Cli/Commands/Repl/ReplSessionContext.cs @@ -126,6 +126,10 @@ public IChatClient StepClient // Plan/execution public PlanStep[]? CurrentPlan; + // The raw text passed to /plan — kept alongside CurrentPlan/ExecutionQueue so the + // adversarial-mode critic can judge each step against what the user actually asked for, + // not just the plan's own (possibly drifted) per-step description. + public string? CurrentPlanRequest; public readonly Queue<(PlanStep Step, int Total)> ExecutionQueue = new(); // Halted plan state — set when a step fails, cleared by /recover or /resume @@ -251,6 +255,7 @@ public void ResetPlanState() { ExecutionQueue.Clear(); CurrentPlan = null; + CurrentPlanRequest = null; HaltedAt = null; HaltedRemaining.Clear(); HaltedToolCalls.Clear(); diff --git a/src/Cli/Commands/Repl/ReplTurn.cs b/src/Cli/Commands/Repl/ReplTurn.cs index 80e23f26..6a06c484 100644 --- a/src/Cli/Commands/Repl/ReplTurn.cs +++ b/src/Cli/Commands/Repl/ReplTurn.cs @@ -764,7 +764,7 @@ private static async Task TryApplyCriticReviewAsync( { if (!ctx.JsonMode) AnsiConsole.Markup("[dim] critic reviewing…[/]"); var (approved, reason) = await ctx.SubAgent.CriticReviewAsync( - input, expectedTool: null, toolCallsThisTurn, responseText, cancellationToken); + input, expectedTool: null, toolCallsThisTurn, responseText, cancellationToken: cancellationToken); if (!ctx.JsonMode) Console.Write($"\r{new string(' ', 40)}\r"); if (!approved) { diff --git a/src/Cli/Commands/Repl/ReplTurnOutcome.cs b/src/Cli/Commands/Repl/ReplTurnOutcome.cs index c197801b..c0c9c662 100644 --- a/src/Cli/Commands/Repl/ReplTurnOutcome.cs +++ b/src/Cli/Commands/Repl/ReplTurnOutcome.cs @@ -90,7 +90,8 @@ internal static async Task HandleStepResult( { if (!ctx.JsonMode) AnsiConsole.Markup("[dim] critic reviewing…[/]"); var (approved, reason) = await ctx.SubAgent.CriticReviewAsync( - activeStep.Description, activeStep.Tool, toolCallsThisTurn, responseText, cancellationToken); + activeStep.Description, activeStep.Tool, toolCallsThisTurn, responseText, + originalUserRequest: ctx.CurrentPlanRequest, cancellationToken: cancellationToken); if (!ctx.JsonMode) Console.Write($"\r{new string(' ', 40)}\r"); if (!approved) { diff --git a/src/Infrastructure/Plugins/SubAgentPlugin.cs b/src/Infrastructure/Plugins/SubAgentPlugin.cs index ea796014..b748024d 100644 --- a/src/Infrastructure/Plugins/SubAgentPlugin.cs +++ b/src/Infrastructure/Plugins/SubAgentPlugin.cs @@ -252,9 +252,11 @@ public async Task DelegateAsync( } // Single-turn critic review — not a model tool (no [Description]). - // Used both for /execute plan steps (taskDescription = step description, expectedTool set) - // and free-form REPL turns under adversarial mode (taskDescription = user input, expectedTool - // null — free-form questions have no single fixed expected tool). + // Used both for /execute plan steps (taskDescription = step description, expectedTool set, + // originalUserRequest = the /plan text so the critic has the real ask, not just the + // plan's own per-step paraphrase) and free-form REPL turns under adversarial mode + // (taskDescription = user input, expectedTool null, originalUserRequest omitted since + // taskDescription already is the user's own words). // Returns (true, null) when approved, (false, reason) when rejected. // Degrades gracefully on timeout or error so a critic failure never blocks execution. public async Task<(bool Approved, string? Reason)> CriticReviewAsync( @@ -262,24 +264,36 @@ public async Task DelegateAsync( string? expectedTool, IReadOnlyList toolsCalled, string agentResponse, + string? originalUserRequest = null, CancellationToken cancellationToken = default) { if (chatClient is null) return (true, null); const string criticSystem = - "You are a strict critic reviewing an AI assistant's response for accuracy. You " + - "receive the task or question, the tools the agent called, and the agent's response. " + - "Judge whether the response is fully correct, grounded in the tool output actually " + - "returned (not fabricated, guessed, or assumed), and completely addresses the task.\n" + - "If it is, respond with exactly:\nAPPROVED\n\n" + + "You are a strict critic reviewing an AI assistant's response. You receive the " + + "user's original request, the specific task or step being judged, the tools the " + + "agent called, and the agent's response. Judge all of the following:\n" + + "1. Correct — fully accurate, grounded in the tool output actually returned " + + "(not fabricated, guessed, or assumed).\n" + + "2. Complete — addresses everything the task/step asked for; nothing silently skipped.\n" + + "3. Right-sized for the user's original request — doesn't leave out something the " + + "request implied, and doesn't add unrequested scope: extra deliverables, files, or " + + "changes beyond what was actually asked. Do NOT count verification actions that " + + "confirm the requested change worked (e.g. re-reading a file just written, checking " + + "a command's exit code) as scope creep — those are expected diligence, not padding.\n" + + "If all three hold, respond with exactly:\nAPPROVED\n\n" + "Otherwise, describe the specific defect in one or two sentences. Be precise — " + - "state what is wrong or missing, not just that something is wrong."; + "state what is wrong, missing, or out of scope — not just that something is wrong."; var toolsStr = toolsCalled.Count > 0 ? string.Join(", ", toolsCalled) : "(none)"; var expectedStr = expectedTool is not null ? $"\nExpected tool: {expectedTool}" : string.Empty; + var requestStr = !string.IsNullOrWhiteSpace(originalUserRequest) && + !originalUserRequest.Equals(taskDescription, StringComparison.Ordinal) + ? $"User's original request: {originalUserRequest}\n" + : string.Empty; var userMsg = - $"Task: {taskDescription}{expectedStr}\n" + + $"{requestStr}Task: {taskDescription}{expectedStr}\n" + $"Tools called: {toolsStr}\n\n" + $"Agent response:\n{agentResponse}"; From df69ce40d978763ea51fa2c516e7f2b3bc181c10 Mon Sep 17 00:00:00 2001 From: Scott Stauffer Date: Sun, 6 Sep 2026 20:36:16 -0500 Subject: [PATCH 5/7] fix(repl): close /safe-mode Extended-bucket gap /safe-mode on only disabled the Shell/Git/Http category-key buckets, so once --plugins Extended was enabled, tools like git_push, git_reset, and shell_run_background lived under the separate "Extended" bucket and stayed callable through safe mode. Add ReplSessionContext.PassesSafeMode(toolName), which blocks a tool by its owning plugin (PluginCapabilityMap.GetPlugin) rather than by which category dictionary key holds it - the same reach /tools restrict already had. Combined with the existing capability-restriction check via a new IsToolAllowed. This also means safe-mode never touches CapabilityRestrictions, so a prior /tools restrict on Shell/Git/Http now survives /safe-mode on/off untouched instead of needing a save/restore dance. FileSystem-owned Extended tools (delete_file, copy_file, etc.) are left alone - safe-mode has never claimed to touch FileSystem. --- docs/cli-reference.md | 4 +- src/Cli/Commands/Repl/ReplCommands.Tools.cs | 54 ++-- src/Cli/Commands/Repl/ReplCommands.cs | 8 +- src/Cli/Commands/Repl/ReplSessionContext.cs | 32 ++- .../ReplSafeModeCommandTests.cs | 236 ++++++++++++++++++ .../ReplToolsRestrictCommandTests.cs | 7 +- 6 files changed, 312 insertions(+), 29 deletions(-) create mode 100644 tests/FuseraftCli.Tests/ReplSafeModeCommandTests.cs diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 1cdffacd..f24f669e 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -439,7 +439,7 @@ Use `/tools` to see the full list at runtime. | `/explore ` | 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 ` | 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 | @@ -543,7 +543,7 @@ Restriction on Git removed. - `/tools unrestrict ` 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** diff --git a/src/Cli/Commands/Repl/ReplCommands.Tools.cs b/src/Cli/Commands/Repl/ReplCommands.Tools.cs index bb2e57ce..1bc3f076 100644 --- a/src/Cli/Commands/Repl/ReplCommands.Tools.cs +++ b/src/Cli/Commands/Repl/ReplCommands.Tools.cs @@ -8,7 +8,10 @@ namespace fuseraft.Cli.Commands.Repl; internal static partial class ReplCommands { - private static readonly string[] SafeModeCategories = { "Shell", "Git", "Http" }; + // Category keys safe-mode disables in ToolsByCategory. Same plugin names as + // ReplSessionContext.SafeModePlugins — the ownership check covers Extended-bucket + // tools that don't live under these keys. + private static readonly string[] SafeModeCategories = ReplSessionContext.SafeModePlugins; // ------------------------------------------------------------------------- // /tools @@ -36,9 +39,12 @@ private static async Task CmdToolsAsync(ReplSessionContext ctx, s : $" [dim] [[{Markup.Escape(catName)}]][/]"); if (!off) foreach (var t in funcs) - AnsiConsole.MarkupLine(ctx.PassesCapabilityRestriction(t.Name) - ? $" [dim] ·[/] {Markup.Escape(t.Name)}" - : $" [dim] ·[/] {Markup.Escape(t.Name)} [dim](restricted)[/]"); + { + var blocked = !ctx.PassesCapabilityRestriction(t.Name) || !ctx.PassesSafeMode(t.Name); + AnsiConsole.MarkupLine(blocked + ? $" [dim] ·[/] {Markup.Escape(t.Name)} [dim](restricted)[/]" + : $" [dim] ·[/] {Markup.Escape(t.Name)}"); + } } if (ctx.CapabilityRestrictions.Count > 0) { @@ -80,10 +86,11 @@ private static async Task CmdToolsAsync(ReplSessionContext ctx, s if (ctx.SafeMode && SafeModeCategories.Contains(match, StringComparer.OrdinalIgnoreCase)) { // Manually re-enabling a category safe mode is managing breaks the - // "safe mode on == Shell/Git/Http disabled" guarantee — drop the flag - // so it doesn't keep claiming a protection that's no longer in effect, - // and so a later `/safe-mode on` actually re-disables things instead of - // no-oping on "already on". + // "safe mode on == Shell/Git/Http blocked" guarantee — drop the flag + // so it doesn't keep claiming a protection that's no longer in effect + // (PassesSafeMode would still block those plugins' tools), and so a + // later `/safe-mode on` actually re-applies instead of no-oping on + // "already on". ctx.SafeMode = false; ctx.PreSafeDisabled = null; AnsiConsole.MarkupLine("[yellow]Safe mode disengaged[/] [dim](re-enabled a category it was managing).[/]"); @@ -127,11 +134,11 @@ private static async Task CmdToolsAsync(ReplSessionContext ctx, s // Fine-grained per-plugin gate — reuses AgentConfig.Capabilities' vocabulary // (read/write/delete/run/...) and PluginCapabilityMap.IsAllowed, the same enforcement - // function orchestration agents are filtered through. Unlike /safe-mode (which disables an - // entire REPL category dictionary key), this filters by each tool's own owning plugin via - // PluginCapabilityMap.GetPlugin, so it also reaches a restricted plugin's tools sitting in - // the "Extended" category — e.g. `/tools restrict Git read` blocks git_push even though - // git_push lives in "Extended", not "Git", once --plugins Extended is enabled. + // function orchestration agents are filtered through. Like /safe-mode's PassesSafeMode + // check, this filters by each tool's own owning plugin via PluginCapabilityMap.GetPlugin, + // so it also reaches a restricted plugin's tools sitting in the "Extended" category — + // e.g. `/tools restrict Git read` blocks git_push even though git_push lives in + // "Extended", not "Git", once --plugins Extended is enabled. private static async Task CmdToolsRestrictAsync(ReplSessionContext ctx, string restrictArg) { var parts = restrictArg.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); @@ -195,12 +202,17 @@ private static async Task CmdToolsRestrictAsync(ReplSessionContext ctx, string r // /safe-mode // ------------------------------------------------------------------------- + // Blocks Shell/Git/Http by owning plugin (via PassesSafeMode + category disable), so + // tools that live in the "Extended" bucket under --plugins Extended are covered too — + // the same per-tool GetPlugin reach /tools restrict already had. FileSystem-owned + // Extended tools are left alone. Any prior /tools restrict on Shell/Git/Http is + // left untouched in CapabilityRestrictions and remains after /safe-mode off. private static async Task CmdSafeModeAsync(ReplSessionContext ctx, string arg) { if (string.IsNullOrEmpty(arg)) { AnsiConsole.MarkupLine(ctx.SafeMode - ? "[dim]Safe mode:[/] [green]on[/] [dim](Shell, Git, Http disabled)[/]" + ? "[dim]Safe mode:[/] [green]on[/] [dim](Shell, Git, Http blocked by owning plugin — including Extended-bucket tools)[/]" : "[dim]Safe mode:[/] [dim]off[/]"); AnsiConsole.MarkupLine("[dim]Run[/] [bold]/safe-mode on[/] [dim]or[/] [bold]/safe-mode off[/][dim].[/]"); return CommandResult.Continue; @@ -214,12 +226,18 @@ private static async Task CmdSafeModeAsync(ReplSessionContext ctx } else { + // Snapshot prior category disables so /safe-mode off can restore them. + // CapabilityRestrictions are intentionally not touched — a prior + // `/tools restrict Git read` (etc.) stays in place under safe mode and + // remains after safe mode is turned off. ctx.PreSafeDisabled = new HashSet(ctx.DisabledCategories, StringComparer.OrdinalIgnoreCase); foreach (var c in SafeModeCategories.Where(c => ctx.ToolsByCategory.ContainsKey(c))) ctx.DisabledCategories.Add(c); ctx.ChatOptions = ctx.BuildChatOptions(); ctx.SafeMode = true; - AnsiConsole.MarkupLine("[dim]Safe mode[/] [green]on[/][dim]: Shell, Git, Http tools disabled.[/]"); + AnsiConsole.MarkupLine( + "[dim]Safe mode[/] [green]on[/][dim]: Shell, Git, Http tools blocked " + + "(by owning plugin, including any in the Extended bucket).[/]"); await ctx.Emitter.EmitAsync(EventTypes.Command, payload: new { command = "/safe-mode on" }); } } @@ -237,7 +255,7 @@ private static async Task CmdSafeModeAsync(ReplSessionContext ctx ctx.PreSafeDisabled = null; ctx.ChatOptions = ctx.BuildChatOptions(); ctx.SafeMode = false; - AnsiConsole.MarkupLine("[dim]Safe mode[/] [dim]off[/][dim]: tool categories restored.[/]"); + AnsiConsole.MarkupLine("[dim]Safe mode[/] [dim]off[/][dim]: prior tool categories restored.[/]"); await ctx.Emitter.EmitAsync(EventTypes.Command, payload: new { command = "/safe-mode off" }); } } @@ -245,8 +263,8 @@ private static async Task CmdSafeModeAsync(ReplSessionContext ctx { AnsiConsole.MarkupLine($"[yellow]Unknown /safe-mode argument:[/] {Markup.Escape(arg)}"); AnsiConsole.MarkupLine("[dim]Usage: /safe-mode — show current status[/]"); - AnsiConsole.MarkupLine("[dim] /safe-mode on — disable Shell, Git, Http tools[/]"); - AnsiConsole.MarkupLine("[dim] /safe-mode off — restore tool categories[/]"); + AnsiConsole.MarkupLine("[dim] /safe-mode on — block Shell, Git, Http tools (incl. Extended-bucket)[/]"); + AnsiConsole.MarkupLine("[dim] /safe-mode off — restore prior tool categories[/]"); } return CommandResult.Continue; } diff --git a/src/Cli/Commands/Repl/ReplCommands.cs b/src/Cli/Commands/Repl/ReplCommands.cs index 7dcb069f..a6050b82 100644 --- a/src/Cli/Commands/Repl/ReplCommands.cs +++ b/src/Cli/Commands/Repl/ReplCommands.cs @@ -101,8 +101,8 @@ private static void PrintHelp(bool jsonMode = false) - `/tools unrestrict ` — Remove a plugin's capability restriction - `/undo` — Revert files written, patched, copied, moved, or deleted in the most recent turn (repeatable; walks back one turn at a time — not the same as `/rewind`, which only affects conversation history) - `/safe-mode` — Show safe mode status - - `/safe-mode on` — Disable Shell, Git, Http tools to prevent mutations - - `/safe-mode off` — Restore tool categories + - `/safe-mode on` — Block Shell, Git, Http tools (by owning plugin, including Extended-bucket tools) + - `/safe-mode off` — Restore prior category disables - `/hitl` — Show HITL (human-in-the-loop) mode status - `/hitl on` — Require y/N approval before each shell command - `/hitl off` — Run shell commands without approval @@ -203,8 +203,8 @@ static Grid MakeGrid() tools.AddRow("[bold cyan]/tools unrestrict [/]", "Remove a plugin's capability restriction"); tools.AddRow("[bold cyan]/undo[/]", "Revert files written/patched/copied/moved/deleted in the most recent turn (repeatable; files only — see /rewind for conversation history)"); tools.AddRow("[bold cyan]/safe-mode[/]", "Show safe mode status"); - tools.AddRow("[bold cyan]/safe-mode on[/]", "Disable Shell, Git, Http tools to prevent mutations"); - tools.AddRow("[bold cyan]/safe-mode off[/]", "Restore tool categories"); + tools.AddRow("[bold cyan]/safe-mode on[/]", "Block Shell, Git, Http tools (incl. Extended-bucket)"); + tools.AddRow("[bold cyan]/safe-mode off[/]", "Restore prior category disables"); tools.AddRow("[bold cyan]/hitl[/]", "Show HITL (human-in-the-loop) mode status"); tools.AddRow("[bold cyan]/hitl on[/]", "Require y/N approval before each shell command"); tools.AddRow("[bold cyan]/hitl off[/]", "Run shell commands without approval"); diff --git a/src/Cli/Commands/Repl/ReplSessionContext.cs b/src/Cli/Commands/Repl/ReplSessionContext.cs index 79ea5a8a..e5fedd58 100644 --- a/src/Cli/Commands/Repl/ReplSessionContext.cs +++ b/src/Cli/Commands/Repl/ReplSessionContext.cs @@ -116,10 +116,18 @@ public IChatClient StepClient // capability tags still allowed for that plugin. Filtering is done per-tool by // PluginCapabilityMap.GetPlugin(toolName) rather than by which REPL category dictionary // key currently holds the tool — so restricting "Git" also covers Git tools sitting in - // the "Extended" category bucket, unlike /safe-mode's category-key-only disable. + // the "Extended" category bucket. /safe-mode uses the same GetPlugin ownership check + // (see PassesSafeMode) in addition to disabling the Shell/Git/Http category keys. public readonly Dictionary> CapabilityRestrictions = new(StringComparer.OrdinalIgnoreCase); + // Plugin names closed off by /safe-mode. Category-key disable covers the curated Core + // buckets; PassesSafeMode covers the same plugins' tools wherever they sit — including + // the Extended bucket — via PluginCapabilityMap.GetPlugin, without touching + // CapabilityRestrictions (so a prior /tools restrict on Shell/Git/Http is preserved + // across safe-mode on/off rather than wiped and needing restore). + public static readonly string[] SafeModePlugins = ["Shell", "Git", "Http"]; + // Conversation public readonly List History; public readonly ConversationCompactor? Compactor; @@ -265,7 +273,27 @@ public void ResetPlanState() public List GetActiveTools() => [.. ToolsByCategory .Where(kv => !DisabledCategories.Contains(kv.Key)) .SelectMany(kv => kv.Value) - .Where(f => PassesCapabilityRestriction(f.Name))]; + .Where(f => IsToolAllowed(f.Name))]; + + /// True when the tool passes both safe-mode and capability-restriction gates. + public bool IsToolAllowed(string toolName) => + PassesSafeMode(toolName) && PassesCapabilityRestriction(toolName); + + /// + /// When safe mode is on, reject tools owned by Shell/Git/Http regardless of which + /// bucket holds them — same GetPlugin ownership check + /// /tools restrict uses, so Extended-bucket tools like git_push and + /// shell_run_background are covered. FileSystem-owned tools are never blocked + /// here; safe-mode has never claimed to touch FileSystem. + /// + public bool PassesSafeMode(string toolName) + { + if (!SafeMode) return true; + var plugin = PluginCapabilityMap.GetPlugin(toolName); + // No capability-map entry (MCP tools, …) — not a Shell/Git/Http built-in. + if (plugin is null) return true; + return !SafeModePlugins.Contains(plugin, StringComparer.OrdinalIgnoreCase); + } public bool PassesCapabilityRestriction(string toolName) { diff --git a/tests/FuseraftCli.Tests/ReplSafeModeCommandTests.cs b/tests/FuseraftCli.Tests/ReplSafeModeCommandTests.cs new file mode 100644 index 00000000..eda1be9f --- /dev/null +++ b/tests/FuseraftCli.Tests/ReplSafeModeCommandTests.cs @@ -0,0 +1,236 @@ +using Microsoft.Extensions.AI; +using fuseraft.Cli.Commands.Repl; +using fuseraft.Core; +using fuseraft.Infrastructure.Chat; +using fuseraft.Infrastructure.KeyStore; + +namespace FuseraftCli.Tests; + +/// +/// Covers /safe-mode: blocks Shell/Git/Http by owning plugin (PluginCapabilityMap.GetPlugin), +/// not only by ToolsByCategory dictionary key. Regression for the Extended-bucket gap where +/// git_push / shell_run_background lived under "Extended" and survived category-key disable. +/// +[Collection("FuseraftHomeEnv")] +public sealed class ReplSafeModeCommandTests : IDisposable +{ + private readonly string? _originalHome = Environment.GetEnvironmentVariable(FuseraftPaths.HomeOverrideEnvVar); + private readonly string _tempHome = Path.Combine(Path.GetTempPath(), $"fuseraft-test-{Guid.NewGuid():N}"); + private readonly List _eventsPaths = []; + private readonly List _contexts = []; + + public ReplSafeModeCommandTests() => + Environment.SetEnvironmentVariable(FuseraftPaths.HomeOverrideEnvVar, _tempHome); + + public void Dispose() + { + Environment.SetEnvironmentVariable(FuseraftPaths.HomeOverrideEnvVar, _originalHome); + if (Directory.Exists(_tempHome)) Directory.Delete(_tempHome, recursive: true); + + foreach (var ctx in _contexts) + { + ctx.Emitter.Dispose(); + ctx.Factory.Dispose(); + } + foreach (var path in _eventsPaths) + if (File.Exists(path)) File.Delete(path); + } + + private sealed class NoopChatClient : IChatClient + { + public ChatClientMetadata Metadata => new("test", null!, "stub"); + public Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, string.Empty))); + public IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + => AsyncEnumerable.Empty(); + public object? GetService(Type serviceType, object? key = null) => null; + public void Dispose() { } + } + + private static AIFunction FakeTool(string name) => + AIFunctionFactory.Create(() => "ok", name, $"Fake tool standing in for {name}."); + + // Mirrors the REPL's real shape with --plugins Extended: Core buckets hold curated tools; + // Extended holds the rest, including Shell/Git tools that category-key disable alone would miss. + private ReplSessionContext NewContextWithExtended(string eventsPath) + { + var toolsByCategory = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["FileSystem"] = [FakeTool("read_file"), FakeTool("write_file")], + ["Shell"] = [FakeTool("shell_run"), FakeTool("shell_get_env")], + ["Git"] = [FakeTool("git_status"), FakeTool("git_commit")], + ["Http"] = [FakeTool("http_get"), FakeTool("http_post")], + ["Extended"] = + [ + FakeTool("git_push"), + FakeTool("git_reset"), + FakeTool("shell_run_background"), + FakeTool("delete_file"), + ], + }; + + return BuildContext(eventsPath, toolsByCategory); + } + + // Core-only shape (no Extended plugin) - safe-mode must keep its prior behavior. + private ReplSessionContext NewContextCoreOnly(string eventsPath) + { + var toolsByCategory = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["FileSystem"] = [FakeTool("read_file"), FakeTool("write_file")], + ["Shell"] = [FakeTool("shell_run"), FakeTool("shell_get_env")], + ["Git"] = [FakeTool("git_status"), FakeTool("git_commit")], + ["Http"] = [FakeTool("http_get")], + }; + + return BuildContext(eventsPath, toolsByCategory); + } + + private ReplSessionContext BuildContext( + string eventsPath, Dictionary> toolsByCategory) + { + _eventsPaths.Add(eventsPath); + var ctx = new ReplSessionContext( + cwd: "/tmp", sessionId: "safe-mode-session", startedAt: DateTime.UtcNow, + modelId: "test-model", modelConfig: new() { ModelId = "test-model" }, + userCfg: null, client: new NoopChatClient(), factory: new ChatClientFactory(), + keyStore: new UnavailableKeyStore(), + emitter: new EventEmitter(eventsPath), + eventsPath: eventsPath, + memoryStore: MemoryStore.CreateForTest(Path.Combine(Path.GetTempPath(), $"fuseraft-test-mem-{Guid.NewGuid():N}")), + toolsByCategory: toolsByCategory, systemPrompt: "test system prompt", pendingSave: false, + adaptiveTrimTracker: new()); + ctx.JsonMode = true; + _contexts.Add(ctx); + return ctx; + } + + private static List ActiveNames(ReplSessionContext ctx) => + [.. ctx.GetActiveTools().Select(f => f.Name)]; + + [Fact] + public async Task SafeModeOn_BlocksCoreShellGitHttpCategories() + { + var ctx = NewContextCoreOnly(Path.Combine(_tempHome, "events-core-on.jsonl")); + + await ReplCommands.HandleAsync(ctx, "/safe-mode", "on", CancellationToken.None); + var names = ActiveNames(ctx); + + Assert.True(ctx.SafeMode); + Assert.DoesNotContain("shell_run", names); + Assert.DoesNotContain("git_commit", names); + Assert.DoesNotContain("http_get", names); + // FileSystem is never a safe-mode target. + Assert.Contains("read_file", names); + Assert.Contains("write_file", names); + } + + [Fact] + public async Task SafeModeOn_BlocksExtendedBucketShellAndGitTools() + { + // The regression: git_push / shell_run_background live under "Extended", not + // "Git"/"Shell", so category-key disable alone left them callable. + var ctx = NewContextWithExtended(Path.Combine(_tempHome, "events-extended-on.jsonl")); + + await ReplCommands.HandleAsync(ctx, "/safe-mode", "on", CancellationToken.None); + var names = ActiveNames(ctx); + + Assert.DoesNotContain("git_push", names); + Assert.DoesNotContain("git_reset", names); + Assert.DoesNotContain("shell_run_background", names); + // FileSystem-owned tool in Extended is untouched. + Assert.Contains("delete_file", names); + Assert.Contains("read_file", names); + Assert.Contains("write_file", names); + } + + [Fact] + public async Task SafeModeOff_RestoresExtendedTools() + { + var ctx = NewContextWithExtended(Path.Combine(_tempHome, "events-extended-off.jsonl")); + + await ReplCommands.HandleAsync(ctx, "/safe-mode", "on", CancellationToken.None); + Assert.DoesNotContain("git_push", ActiveNames(ctx)); + + await ReplCommands.HandleAsync(ctx, "/safe-mode", "off", CancellationToken.None); + var names = ActiveNames(ctx); + + Assert.False(ctx.SafeMode); + Assert.Contains("git_push", names); + Assert.Contains("shell_run_background", names); + Assert.Contains("shell_run", names); + Assert.Contains("git_commit", names); + } + + [Fact] + public async Task SafeModeOff_PreservesPriorCapabilityRestriction() + { + // A prior /tools restrict must not be wiped by safe-mode; turning safe mode off + // should leave the restriction in effect (not restore full Git write access). + var ctx = NewContextWithExtended(Path.Combine(_tempHome, "events-prior-restrict.jsonl")); + + await ReplCommands.HandleAsync(ctx, "/tools", "restrict Git read", CancellationToken.None); + Assert.DoesNotContain("git_commit", ActiveNames(ctx)); + Assert.DoesNotContain("git_push", ActiveNames(ctx)); + Assert.Contains("git_status", ActiveNames(ctx)); + + await ReplCommands.HandleAsync(ctx, "/safe-mode", "on", CancellationToken.None); + Assert.DoesNotContain("git_status", ActiveNames(ctx)); // safe-mode blocks all Git + + await ReplCommands.HandleAsync(ctx, "/safe-mode", "off", CancellationToken.None); + var names = ActiveNames(ctx); + + // Restriction restored/preserved: read-only Git still holds. + Assert.Contains("git_status", names); + Assert.DoesNotContain("git_commit", names); + Assert.DoesNotContain("git_push", names); + Assert.True(ctx.CapabilityRestrictions.ContainsKey("Git")); + } + + [Fact] + public async Task SafeModeOff_RestoresPriorCategoryDisable() + { + var ctx = NewContextCoreOnly(Path.Combine(_tempHome, "events-prior-disable.jsonl")); + + await ReplCommands.HandleAsync(ctx, "/tools", "disable Shell", CancellationToken.None); + Assert.DoesNotContain("shell_run", ActiveNames(ctx)); + Assert.Contains("git_status", ActiveNames(ctx)); + + await ReplCommands.HandleAsync(ctx, "/safe-mode", "on", CancellationToken.None); + await ReplCommands.HandleAsync(ctx, "/safe-mode", "off", CancellationToken.None); + + var names = ActiveNames(ctx); + // Pre-safe Shell disable is restored; Git (which safe-mode had disabled) comes back. + Assert.DoesNotContain("shell_run", names); + Assert.Contains("git_status", names); + Assert.Contains("http_get", names); + } + + [Fact] + public async Task SafeModeOn_AlreadyOn_IsNoOp() + { + var ctx = NewContextCoreOnly(Path.Combine(_tempHome, "events-already-on.jsonl")); + + await ReplCommands.HandleAsync(ctx, "/safe-mode", "on", CancellationToken.None); + var countAfterFirst = ActiveNames(ctx).Count; + + var result = await ReplCommands.HandleAsync(ctx, "/safe-mode", "on", CancellationToken.None); + + Assert.Equal(CommandOutcome.Continue, result.Outcome); + Assert.True(ctx.SafeMode); + Assert.Equal(countAfterFirst, ActiveNames(ctx).Count); + } + + [Fact] + public void PassesSafeMode_WhenOff_AllowsEverything() + { + var ctx = NewContextWithExtended(Path.Combine(_tempHome, "events-pass-off.jsonl")); + + Assert.True(ctx.PassesSafeMode("git_push")); + Assert.True(ctx.PassesSafeMode("shell_run_background")); + Assert.True(ctx.PassesSafeMode("delete_file")); + Assert.True(ctx.PassesSafeMode("read_file")); + } +} diff --git a/tests/FuseraftCli.Tests/ReplToolsRestrictCommandTests.cs b/tests/FuseraftCli.Tests/ReplToolsRestrictCommandTests.cs index 964fd824..2aeb9c64 100644 --- a/tests/FuseraftCli.Tests/ReplToolsRestrictCommandTests.cs +++ b/tests/FuseraftCli.Tests/ReplToolsRestrictCommandTests.cs @@ -12,10 +12,11 @@ namespace FuseraftCli.Tests; /// AgentConfig.Capabilities is filtered through in orchestration) instead of REPL's own /// whole-category /safe-mode / /tools disable toggles. /// -/// is the key differentiator from -/// /safe-mode: filtering happens per-tool by PluginCapabilityMap.GetPlugin(toolName), not by -/// which ReplSessionContext.ToolsByCategory dictionary key currently holds the tool — so a +/// proves the cross-bucket reach: +/// filtering happens per-tool by PluginCapabilityMap.GetPlugin(toolName), not by which +/// ReplSessionContext.ToolsByCategory dictionary key currently holds the tool — so a /// restricted plugin's tools sitting in the "Extended" bucket are covered too. +/// (/safe-mode uses the same ownership check; see ReplSafeModeCommandTests.) /// [Collection("FuseraftHomeEnv")] public sealed class ReplToolsRestrictCommandTests : IDisposable From 9e0dbe5e08fa7e3511483213e9600cfc9561eb02 Mon Sep 17 00:00:00 2001 From: Scott Stauffer Date: Sun, 6 Sep 2026 20:39:48 -0500 Subject: [PATCH 6/7] feat(skills): add repl-tmux-driver skill Captures the tmux workflow for driving fuseraft repl interactively from outside - injecting single/multi-line input, polling for the idle prompt instead of blind-sleeping, capturing pane output, and verifying results independently rather than trusting the agent's own summary. Written up after using this exact procedure to have a live REPL session fix the /safe-mode Extended-bucket gap. --- skills/repl-tmux-driver/SKILL.md | 119 +++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 skills/repl-tmux-driver/SKILL.md diff --git a/skills/repl-tmux-driver/SKILL.md b/skills/repl-tmux-driver/SKILL.md new file mode 100644 index 00000000..03758f17 --- /dev/null +++ b/skills/repl-tmux-driver/SKILL.md @@ -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 " <- 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 2>/dev/null # safe no-op if it doesn't already exist +tmux new-session -d -s -x 220 -y 50 -c +tmux send-keys -t "./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 -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 ` to continue a prior one. + +### Step 3: Send input + +**Single-line message:** send it directly. + +```bash +tmux send-keys -t "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 "/paste" Enter +tmux load-buffer -b task_buf /path/to/task.txt +tmux paste-buffer -b task_buf -t +tmux send-keys -t Enter +tmux send-keys -t ".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 `... (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 -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 -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 "/exit" Enter +sleep 2 +tmux kill-session -t 2>/dev/null +``` + +Note the "Resume with: fuseraft --resume " 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 From 8d7a3d3831db5ad014675ba3285f7915098a454c Mon Sep 17 00:00:00 2001 From: Scott Stauffer Date: Sun, 6 Sep 2026 20:41:37 -0500 Subject: [PATCH 7/7] docs(skills): add repl-tmux-driver to the shipped skills catalog Was missing from the "Shipped skills" list added alongside the skill itself in 9e0dbe5. --- docs/skills.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/skills.md b/docs/skills.md index a5eb639f..78982989 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -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.