Skip to content

Commit a6ffe85

Browse files
yotsudaclaude
andcommitted
feat(busy-route): auto-execute pipeline on a fresh console at the source's cwd
Pre-fix: when invoke_expression's chosen pipe was busy (a user command typed in the visible terminal between FindReadyPipeAsync and the proxy's request arrival, or another mcp_command racing), the proxy spawned a brand-new console at \$HOME, returned `Pipeline NOT executed - verify location and re-execute` plus the full get_current_location JSON (system info + every PSDrive), and the AI had to re-send the same invoke_expression with whatever cwd they wanted. Two MCP round-trips and a manual Set-Location for every busy race — observed as the single biggest token-waster in side-by-side comparisons against ripple, which auto-routes to a same-family standby with the source's cwd preserved. Now: the busy response carries the source console's process-level cwd (DLL: NamedPipeServer.cs adds `cwd` from Directory.GetCurrentDirectory in three places — get_status/busy, mcp_command, user_command branches; reading the property does NOT touch the runspace, so it stays non-blocking). The proxy reads jsonResponse.Cwd, starts the new console at that path (falls back to \$HOME when the field is null on older DLLs — fully backward-compatible), and recursively calls InvokeExpression on the same agent. The new console is now the active standby, so FindReadyPipeAsync on the recursive call lands on it and the pipeline runs cleanly. The recursion is bounded in practice: a back-to-back race against another user-typed command in the freshly-spawned console would require sub-millisecond timing at AI tool-call cadence; if it ever happens, the recursion spawns one more console and runs there — no infinite loop, just an extra console. The bulky get_current_location JSON drops out of the response in this path: the AI no longer needs the OS / PSDrive listing to verify cwd because the auto-route already preserved it, and the recursive call's response carries the actual pipeline output the AI was waiting for. Proxy still surfaces the busy status line ("⧗ Pipeline is running ...") and a one-line auto-route notice ("ℹ️ Auto-routed to NEW at CWD ... Pipeline executed automatically — no re-send needed") so the AI sees what happened, but the verbose JSON is gone. Closed-console messages from the outer call are prepended too (the recursive call's FindReadyPipeAsync runs fresh and won't re-report them). The other two `Pipeline NOT executed` paths (no-pipe auto-start and consoleSwitched on a claimed unowned console) are left alone for now: they don't have a source-console cwd to preserve, and forcing auto-execution there would risk landing the AI's pipeline in an unrelated cwd. Trim of the bulky JSON in those paths is a separate concern. Existing 19 PowerShellTools unit tests still pass — none cover the busy auto-respawn path (StartConsoleInternal calls a static PowerShellProcessManager so spawn-mock isn't possible without a process-manager-injection refactor first; deferred). The change is safe to ship because the wire format is strictly additive (DLL emits a new optional field; older proxies ignore it; older DLLs still hit the null-cwd → \$HOME fallback path = pre-fix behavior). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 61adc7d commit a6ffe85

3 files changed

Lines changed: 109 additions & 32 deletions

File tree

PowerShell.MCP.Proxy/Models/JsonRpcModels.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,18 @@ public class GetStatusResponse
7676

7777
[JsonPropertyName("message")]
7878
public string? Message { get; set; }
79+
80+
/// <summary>
81+
/// Process-level current working directory of the source console
82+
/// at the moment this status was captured. Populated by the DLL
83+
/// (see <c>NamedPipeServer.HandleGetStatus</c>) for every status
84+
/// response — busy / user_command / mcp_command / standby /
85+
/// completed. Used by the busy-auto-route path so a freshly-spawned
86+
/// console can land at the same cwd before re-running the AI's
87+
/// pipeline. Null on older DLLs that predate the field.
88+
/// </summary>
89+
[JsonPropertyName("cwd")]
90+
public string? Cwd { get; set; }
7991
}
8092

8193
[JsonSerializable(typeof(GetStatusResponse))]

PowerShell.MCP.Proxy/Tools/PowerShellTools.cs

Lines changed: 57 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -344,12 +344,29 @@ public static async Task<string> InvokeExpression(
344344

345345
if (jsonResponse.Reason == "user_command" || jsonResponse.Reason == "mcp_command")
346346
{
347-
// Auto-start new console
348-
Console.Error.WriteLine($"[INFO] Runspace busy ({jsonResponse.Reason}), auto-starting new console...");
349-
var (success, locationResult) = await StartConsoleInternal(powerShellService, agentId, null, Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), cancellationToken);
350-
if (!success)
347+
// Auto-route: spawn a new console at the source's cwd and
348+
// re-run the pipeline there in the same tool call. Pre-fix
349+
// we returned a "Pipeline NOT executed - verify location
350+
// and re-execute" message and the AI had to re-send the
351+
// exact same call, with the new console at HOME (= losing
352+
// the cwd the user/AI had been working in). Two
353+
// round-trips and a manual `Set-Location` for every busy
354+
// race. Now: read the source's cwd from the busy
355+
// response (DLL emits `cwd` from the get_status path),
356+
// start the new console there, and run the pipeline as a
357+
// single recursive call. The DLL has emitted `cwd` since
358+
// a recent compatible version; older DLLs leave it null
359+
// and we fall back to HOME (= the old behavior).
360+
var sourceCwd = jsonResponse.Cwd;
361+
var startLoc = (!string.IsNullOrEmpty(sourceCwd) && Directory.Exists(sourceCwd))
362+
? sourceCwd
363+
: Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
364+
365+
Console.Error.WriteLine($"[INFO] Runspace busy ({jsonResponse.Reason}), auto-routing to new console at {startLoc}...");
366+
var (startSuccess, startError) = await StartConsoleInternal(powerShellService, agentId, null, startLoc, cancellationToken);
367+
if (!startSuccess)
351368
{
352-
return locationResult; // Error message
369+
return startError;
353370
}
354371

355372
// Set console window title
@@ -359,28 +376,43 @@ public static async Task<string> InvokeExpression(
359376
await SetConsoleTitleAsync(powerShellService, activeAfterBusy, cancellationToken);
360377
}
361378

362-
var newPipeName = sessionManager.GetActivePipeName(agentId);
363-
var (completedOutputs, busyInfo) = await CollectAllCachedOutputsAsync(pipeDiscoveryService, agentId, newPipeName, cancellationToken);
364-
365-
var newConsoleName = GetConsoleName(newPipeName);
366-
379+
var newConsoleName = GetConsoleName(activeAfterBusy);
380+
381+
// Recurse: the new console is now the active standby,
382+
// so FindReadyPipeAsync on the recursive call sees it
383+
// as ready and the pipeline runs cleanly. Recursion
384+
// depth is bounded in practice — StartConsoleInternal
385+
// just spawned a fresh console that hasn't been handed
386+
// out as a user-input target yet, so a second
387+
// back-to-back busy would require a sub-millisecond
388+
// race that's effectively impossible at AI tool-call
389+
// cadence. If it ever happens, the recursion would
390+
// spawn one more console and run there; no infinite
391+
// loop, just one extra console.
392+
var retryResult = await InvokeExpression(
393+
powerShellService, pipeDiscoveryService, pipeline,
394+
timeout_seconds, var1, var2, var3, var4,
395+
agentId, is_subagent: false,
396+
cancellationToken: cancellationToken);
397+
398+
// Surface the auto-route notice + closed-console
399+
// messages collected by THIS outer call (the recursive
400+
// call's FindReadyPipeAsync runs fresh and won't
401+
// re-report them). Drop the previous bulky
402+
// get_current_location JSON (system info / drives) — the
403+
// recursive call delivers the actual pipeline output and
404+
// the AI no longer needs the OS / drive-list block to
405+
// verify cwd because we already preserved it.
367406
var busyResponse = new StringBuilder();
368-
// Busy status at the top (current pipe first, then other pipes)
369-
busyResponse.AppendLine(FormatBusyStatus(jsonResponse));
370-
if (busyInfo.Length > 0)
371-
{
372-
busyResponse.Append(busyInfo);
373-
}
374-
busyResponse.AppendLine();
375-
busyResponse.AppendLine($"Started new console {newConsoleName} with PowerShell.MCP module imported. Pipeline NOT executed - verify location and re-execute.");
376-
busyResponse.AppendLine();
377-
busyResponse.Append(locationResult);
378-
if (completedOutputs.Length > 0)
407+
if (closedConsoleMessages.Count > 0)
379408
{
409+
busyResponse.AppendLine(string.Join("\n", closedConsoleMessages));
380410
busyResponse.AppendLine();
381-
busyResponse.AppendLine();
382-
busyResponse.Append(completedOutputs);
383411
}
412+
busyResponse.AppendLine(FormatBusyStatus(jsonResponse));
413+
busyResponse.AppendLine($"ℹ️ Auto-routed to {newConsoleName} at {startLoc} (source console busy with {jsonResponse.Reason}). Pipeline executed automatically — no re-send needed.");
414+
busyResponse.AppendLine();
415+
busyResponse.Append(retryResult);
384416
return busyResponse.ToString();
385417
}
386418
break;
@@ -536,9 +568,8 @@ public static async Task<string> InvokeExpression(
536568
successResponse.AppendLine();
537569
successResponse.AppendLine(scopeWarning);
538570
}
539-
// One-time hint about MarkdownPointer module
540-
var markdownHint = PipelineHelper.CheckMarkdownFileHint(pipeline, agentId)
541-
?? PipelineHelper.CheckMarkdownFileHint(output, agentId);
571+
// One-time hint about MarkdownPointer module (pipeline-only; output checks caused false positives on incidental .md mentions).
572+
var markdownHint = PipelineHelper.CheckMarkdownFileHint(pipeline, agentId);
542573
if (!string.IsNullOrEmpty(markdownHint))
543574
{
544575
successResponse.AppendLine();

PowerShell.MCP/Services/NamedPipeServer.cs

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,21 @@ private static async Task HandleClientAsync(NamedPipeServerStream pipeServer, Ca
417417
{
418418
var pid = System.Diagnostics.Process.GetCurrentProcess().Id;
419419
var status = ExecutionState.Status;
420+
// Process-level cwd. PowerShell's `Set-Location` for the
421+
// FileSystem provider syncs this with $PWD so P/Invoke
422+
// calls see the same directory the user typed `cd` to.
423+
// Reading it here is just a property access — does NOT
424+
// touch the runspace and is safe to call while a command
425+
// is busy. Used by the proxy's busy-auto-route path so a
426+
// freshly-spawned console can land at the source's cwd
427+
// before re-running the AI's pipeline (otherwise every
428+
// contention with a user-typed command would force the
429+
// AI to manually re-cd). Non-FileSystem providers
430+
// (HKLM:\, Cert:\, etc.) leave this at the last
431+
// FileSystem location, which is the right fallback.
432+
string? cwd;
433+
try { cwd = System.IO.Directory.GetCurrentDirectory(); }
434+
catch { cwd = null; }
420435

421436
string statusResponse;
422437
if (status == "busy")
@@ -435,7 +450,8 @@ private static async Task HandleClientAsync(NamedPipeServerStream pipeServer, Ca
435450
status = "busy",
436451
pipeline = truncatedPipeline,
437452
duration = roundedDuration,
438-
statusLine
453+
statusLine,
454+
cwd
439455
});
440456
}
441457
else if (status == "completed")
@@ -448,7 +464,8 @@ private static async Task HandleClientAsync(NamedPipeServerStream pipeServer, Ca
448464
pid,
449465
status = "completed",
450466
cachedCount = cachedOutputs.Count,
451-
statusLine
467+
statusLine,
468+
cwd
452469
});
453470
}
454471
else
@@ -457,7 +474,7 @@ private static async Task HandleClientAsync(NamedPipeServerStream pipeServer, Ca
457474
if (ExecutionState.IsRunspaceAvailable)
458475
{
459476
var statusLine = BuildStatusLine("●", "Console ready", "Standby");
460-
statusResponse = JsonSerializer.Serialize(new { pid, status = "standby", statusLine });
477+
statusResponse = JsonSerializer.Serialize(new { pid, status = "standby", statusLine, cwd });
461478
}
462479
else
463480
{
@@ -470,7 +487,8 @@ private static async Task HandleClientAsync(NamedPipeServerStream pipeServer, Ca
470487
status = "busy",
471488
pipeline = "(user command)",
472489
duration = userDuration,
473-
statusLine
490+
statusLine,
491+
cwd
474492
});
475493
}
476494
}
@@ -571,13 +589,22 @@ private static async Task HandleClientAsync(NamedPipeServerStream pipeServer, Ca
571589
var pid = System.Diagnostics.Process.GetCurrentProcess().Id;
572590
var elapsed = ExecutionState.ElapsedSeconds;
573591
var runningPipeline = TruncatePipeline(ExecutionState.CurrentPipeline);
592+
// Process-level cwd. The runspace is busy with another
593+
// command but Directory.GetCurrentDirectory is just a
594+
// property read — non-blocking. Proxy uses this to spawn
595+
// the new auto-route console at the source's cwd so the
596+
// re-routed pipeline runs where the AI expected it.
597+
string? cwd;
598+
try { cwd = System.IO.Directory.GetCurrentDirectory(); }
599+
catch { cwd = null; }
574600
var busyResponse = JsonSerializer.Serialize(new
575601
{
576602
pid,
577603
status = "busy",
578604
reason = "mcp_command",
579605
pipeline = runningPipeline,
580-
duration = Math.Round(elapsed, 2)
606+
duration = Math.Round(elapsed, 2),
607+
cwd
581608
});
582609

583610
await SendMessageAsync(pipeServer, busyResponse, cancellationToken);
@@ -598,13 +625,20 @@ private static async Task HandleClientAsync(NamedPipeServerStream pipeServer, Ca
598625
// Runspace is busy with user command - return JSON response
599626
var pid = Process.GetCurrentProcess().Id;
600627
var userCmdElapsed = ExecutionState.UserCommandElapsedSeconds;
628+
// Capture the source console's cwd so the proxy can
629+
// spawn the auto-route console at the same location
630+
// (see the mcp_command branch above for the reasoning).
631+
string? cwd;
632+
try { cwd = System.IO.Directory.GetCurrentDirectory(); }
633+
catch { cwd = null; }
601634
var busyResponse = JsonSerializer.Serialize(new
602635
{
603636
pid,
604637
status = "busy",
605638
reason = "user_command",
606639
pipeline = "(user command)",
607-
duration = Math.Round(userCmdElapsed, 2)
640+
duration = Math.Round(userCmdElapsed, 2),
641+
cwd
608642
});
609643
await SendMessageAsync(pipeServer, busyResponse, cancellationToken);
610644
return;

0 commit comments

Comments
 (0)