Skip to content

Commit 32f0f56

Browse files
committed
feat(cwd): track PSDrive cwd via home-thread cache, bail on user-cd drift
The DLL was capturing process cwd via Directory.GetCurrentDirectory() — but PowerShell's Set-Location updates only $PWD/PSDrive, not process cwd. AI cwd tracking was silently lying: LastAiCwd was pinned to startup dir forever, drift detection compared startup dir against process cwd, and busy-route / auto-start spawned new consoles at HOME instead of resuming AI's workspace. Capture $PWD on the polling engine's home thread (timer Action + before NotifyResultReady) into ExecutionState.CurrentAiCwd. NamedPipeServer reads the cache for every cwd-emitting response — busy / mcp_command / get_status / post-execution success, timeout, completed. Drift policy is now safety-first bail instead of silent auto-cd: if the user typed cd in the visible console between AI calls, the proxy returns a "Pipeline NOT executed" notice with prev → new cwd, a Set-Location revert hint (single-quote-escaped), and updates LastAiCwd to liveCwd to clear the state. AI re-issues to accept the new cwd, or prepends the revert. Auto-start path also gains a session-scoped LastAiCwd so a console death doesn't pin the new console at HOME — it resumes where AI was working. Tests: 270 unit tests including drift bail + re-issue + session fallback + auto-start preservation. Smoke-tested end-to-end against PowerShell.MCP and ripple (parallel fix in C:/MyProj/ripple).
1 parent 04170e6 commit 32f0f56

8 files changed

Lines changed: 1002 additions & 162 deletions

File tree

PowerShell.MCP.Proxy/Services/ConsoleSessionManager.cs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,38 @@ private class AgentSessionState
6565
/// </summary>
6666
private readonly Dictionary<int, string> _pidToTitle = new();
6767

68+
/// <summary>
69+
/// Maps pwsh PIDs to the cwd the AI's most recent successful (or
70+
/// timeout / cached) <c>invoke_expression</c> ended at. Captured from
71+
/// the DLL's response header (which now carries <c>cwd</c>) so the
72+
/// proxy never has to re-query a busy / dead pipe to know "where the
73+
/// AI thinks it is". Used to:
74+
/// - inject a <c>Set-Location</c> preamble when the live cwd has
75+
/// drifted (user typed <c>cd</c> in the visible console between
76+
/// AI calls);
77+
/// - choose <c>start_location</c> for busy-auto-route's spawn so
78+
/// the new console picks up at AI's intended cwd, not the
79+
/// user-touched live cwd of the busy source;
80+
/// - resume at the right place after the active console died and
81+
/// we had to switch to a sibling owned pipe.
82+
/// Cleared with <see cref="ClearDeadPipe"/> so a recycled PID can't
83+
/// pull a stale entry.
84+
/// </summary>
85+
private readonly Dictionary<int, string> _pidToLastAiCwd = new();
86+
87+
/// <summary>
88+
/// Session-scoped fallback for the agent's most recently observed AI
89+
/// cwd, surviving the death of the per-pid <c>_pidToLastAiCwd</c>
90+
/// entry. Updated alongside the per-pid entry whenever a pipe reports
91+
/// a successful invoke_expression cwd. Used as the
92+
/// <c>start_location</c> when invoke_expression auto-starts a console
93+
/// (no surviving pipe means no per-pid entry to fall back to, but the
94+
/// AI was still working *somewhere* and that somewhere is the right
95+
/// place for the new console to land — same rationale as busy-route).
96+
/// Cleared only when the agent's session is evicted entirely.
97+
/// </summary>
98+
private readonly Dictionary<string, string> _agentToLastAiCwd = new();
99+
68100
/// <summary>
69101
/// Prefix for server-generated sub-agent IDs
70102
/// </summary>
@@ -247,13 +279,15 @@ public void ClearDeadPipe(string agentId, string pipeName)
247279
{
248280
state.KnownBusyPids.Remove(pid.Value);
249281
_pidToTitle.Remove(pid.Value);
282+
_pidToLastAiCwd.Remove(pid.Value);
250283
}
251284
Console.Error.WriteLine($"[INFO] ConsoleSessionManager: Cleared dead pipe '{pipeName}' (agent={agentId})");
252285

253286
// Remove empty agent session to prevent memory accumulation
254287
if (agentId != "default" && state.ActivePipeName == null && state.KnownBusyPids.Count == 0)
255288
{
256289
_agentSessions.Remove(agentId);
290+
_agentToLastAiCwd.Remove(agentId);
257291
}
258292
}
259293
}
@@ -378,6 +412,58 @@ public IEnumerable<string> EnumerateUnownedPipes()
378412
}
379413
}
380414

415+
/// <summary>
416+
/// Records the cwd the AI's most recent <c>invoke_expression</c> ended at
417+
/// for the given pwsh PID. Called after a successful (or timeout / cached)
418+
/// pipe call by the proxy, with the cwd field the DLL emits in its
419+
/// response header. Also updates the agent-scoped fallback so a later
420+
/// invoke_expression that has to auto-start a console can resume at
421+
/// the same place even after the pipe (and its per-pid entry) has died.
422+
/// Pass null to clear (e.g., on switch to a fresh console with no
423+
/// prior AI history) — only the per-pid entry is cleared; the
424+
/// agent-level fallback is preserved.
425+
/// </summary>
426+
public void SetLastAiCwd(string agentId, int pwshPid, string? cwd)
427+
{
428+
lock (_lock)
429+
{
430+
if (string.IsNullOrEmpty(cwd))
431+
{
432+
_pidToLastAiCwd.Remove(pwshPid);
433+
}
434+
else
435+
{
436+
_pidToLastAiCwd[pwshPid] = cwd;
437+
_agentToLastAiCwd[agentId] = cwd;
438+
}
439+
}
440+
}
441+
442+
/// <summary>
443+
/// Returns the cwd recorded by the most recent successful AI command on
444+
/// this PID, or null if no AI command has executed there yet (newly
445+
/// claimed unowned pipe, freshly auto-started console where the AI's
446+
/// pipeline was redirected mid-busy, etc.). Callers should fall back
447+
/// to the live cwd from get_status when null.
448+
/// </summary>
449+
public string? GetLastAiCwd(int pwshPid)
450+
{
451+
lock (_lock) return _pidToLastAiCwd.TryGetValue(pwshPid, out var cwd) ? cwd : null;
452+
}
453+
454+
/// <summary>
455+
/// Returns the agent-scoped cwd fallback used when no per-pid entry
456+
/// exists — typically because the pipe the AI was working on died
457+
/// before the next tool call and we now need to spawn a fresh console.
458+
/// Returns null when this agent has never recorded an AI cwd
459+
/// (brand-new agent, unowned-claim only history, etc.); callers should
460+
/// fall back to <c>UserProfile</c>.
461+
/// </summary>
462+
public string? GetSessionLastAiCwd(string agentId)
463+
{
464+
lock (_lock) return _agentToLastAiCwd.TryGetValue(agentId, out var cwd) ? cwd : null;
465+
}
466+
381467
/// <summary>
382468
/// Gets the console title for a PID, or a fallback "PID #xxx" string if not titled.
383469
/// </summary>

PowerShell.MCP.Proxy/Services/IPipeDiscoveryService.cs

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,21 @@ namespace PowerShell.MCP.Proxy.Services;
33
/// <summary>
44
/// Result of pipe discovery operation
55
/// </summary>
6+
/// <param name="LiveCwd">
7+
/// The cwd reported by get_status on the chosen ReadyPipeName, or null if
8+
/// the pipe didn't reply with a cwd (older DLL, network drive disconnected,
9+
/// or the discovery path that landed here didn't probe). Used by
10+
/// invoke_expression's drift check to compare against
11+
/// <c>ConsoleSessionManager.GetLastAiCwd</c> and inject a Set-Location
12+
/// preamble when the user has typed <c>cd</c> in the visible console
13+
/// between AI calls.
14+
/// </param>
615
public record PipeDiscoveryResult(
716
string? ReadyPipeName,
817
bool ConsoleSwitched,
918
IReadOnlyList<string> ClosedConsoleMessages,
10-
string? AllPipesStatusInfo
19+
string? AllPipesStatusInfo,
20+
string? LiveCwd = null
1121
);
1222

1323
/// <summary>
@@ -24,9 +34,22 @@ string BusyStatusInfo
2434
public interface IPipeDiscoveryService
2535
{
2636
/// <summary>
27-
/// Find a ready pipe for command execution
37+
/// Find a ready pipe for command execution.
2838
/// </summary>
29-
Task<PipeDiscoveryResult> FindReadyPipeAsync(string agentId, CancellationToken cancellationToken);
39+
/// <param name="includeUnowned">
40+
/// When true (default), Step 3 of discovery scans unowned pipes — pwsh
41+
/// processes the user spawned manually and ran <c>Import-Module
42+
/// PowerShell.MCP</c> on. The first ready unowned pipe is claimed.
43+
/// When false, unowned pipes are skipped and the method returns null
44+
/// instead of claiming. Used by <c>start_console</c> without an
45+
/// explicit <c>start_location</c>: the AI hasn't pinned its intended
46+
/// cwd, so claiming an arbitrary user-set cwd would mislead it. A
47+
/// fresh console at the proxy's default home is the predictable
48+
/// baseline. <c>invoke_expression</c> and <c>get_current_location</c>
49+
/// keep the default <c>true</c> because the AI is actively working
50+
/// and benefits from the user's existing module-loaded environment.
51+
/// </param>
52+
Task<PipeDiscoveryResult> FindReadyPipeAsync(string agentId, CancellationToken cancellationToken, bool includeUnowned = true);
3053

3154
/// <summary>
3255
/// Collect cached outputs from all pipes except the specified one

PowerShell.MCP.Proxy/Services/PipeDiscoveryService.cs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public IReadOnlyList<string> DetectClosedConsoles(string agentId, int? excludePi
4343
}
4444

4545
/// <inheritdoc />
46-
public async Task<PipeDiscoveryResult> FindReadyPipeAsync(string agentId, CancellationToken cancellationToken)
46+
public async Task<PipeDiscoveryResult> FindReadyPipeAsync(string agentId, CancellationToken cancellationToken, bool includeUnowned = true)
4747
{
4848
// Fast path: check active pipe before any expensive operations (EnumeratePipes, DetectClosedConsoles)
4949
var activePipe = _sessionManager.GetActivePipeName(agentId);
@@ -54,7 +54,7 @@ public async Task<PipeDiscoveryResult> FindReadyPipeAsync(string agentId, Cancel
5454
if (activePipeStatus != null && activePipeStatus.IsReady())
5555
{
5656
if (activePipeStatus.Pid > 0) _sessionManager.UnmarkPipeBusy(agentId, activePipeStatus.Pid);
57-
return new PipeDiscoveryResult(activePipe, false, [], null);
57+
return new PipeDiscoveryResult(activePipe, false, [], null, activePipeStatus.Cwd);
5858
}
5959
}
6060

@@ -79,7 +79,7 @@ public async Task<PipeDiscoveryResult> FindReadyPipeAsync(string agentId, Cancel
7979
{
8080
// Became ready between fast path and slow path (after DetectClosedConsoles)
8181
if (activePipeStatus.Pid > 0) _sessionManager.UnmarkPipeBusy(agentId, activePipeStatus.Pid);
82-
return new PipeDiscoveryResult(activePipe, false, closedMessages, BuildClosedConsoleInfo(closedMessages));
82+
return new PipeDiscoveryResult(activePipe, false, closedMessages, BuildClosedConsoleInfo(closedMessages), activePipeStatus.Cwd);
8383
}
8484
else // busy
8585
{
@@ -108,14 +108,21 @@ public async Task<PipeDiscoveryResult> FindReadyPipeAsync(string agentId, Cancel
108108
{
109109
if (status.Pid > 0) _sessionManager.UnmarkPipeBusy(agentId, status.Pid);
110110
_sessionManager.SetActivePipeName(agentId, pipeName);
111-
return new PipeDiscoveryResult(pipeName, true, closedMessages, BuildClosedConsoleInfo(closedMessages));
111+
return new PipeDiscoveryResult(pipeName, true, closedMessages, BuildClosedConsoleInfo(closedMessages), status.Cwd);
112112
}
113113

114114
if (status.Pid > 0) _sessionManager.MarkPipeBusy(agentId, status.Pid);
115115
allPipesStatus.Add($" - {pipeName}: {status.Status} (pipeline: {status.Pipeline ?? "unknown"}, duration: {status.Duration:F1}s)");
116116
}
117117

118-
// Step 3: Check unowned pipes (user-started consoles not yet claimed by any proxy)
118+
// Step 3: Check unowned pipes (user-started consoles not yet claimed
119+
// by any proxy). Caller can opt out via includeUnowned=false — see
120+
// start_console's no-start_location branch for the rationale.
121+
if (!includeUnowned)
122+
{
123+
return new PipeDiscoveryResult(null, false, closedMessages, BuildClosedConsoleInfo(closedMessages));
124+
}
125+
119126
foreach (var pipeName in _sessionManager.EnumerateUnownedPipes())
120127
{
121128
var status = await _powerShellService.GetStatusFromPipeAsync(pipeName, cancellationToken);
@@ -153,7 +160,7 @@ public async Task<PipeDiscoveryResult> FindReadyPipeAsync(string agentId, Cancel
153160
if (newStatus != null)
154161
{
155162
_sessionManager.SetActivePipeName(agentId, newPipeName);
156-
return new PipeDiscoveryResult(newPipeName, true, closedMessages, BuildClosedConsoleInfo(closedMessages));
163+
return new PipeDiscoveryResult(newPipeName, true, closedMessages, BuildClosedConsoleInfo(closedMessages), newStatus.Cwd);
157164
}
158165
}
159166
}

0 commit comments

Comments
 (0)