Skip to content

pane_current_command diverges from tmux and blocks reliable session restore #299

@MattKotsenas

Description

@MattKotsenas

pane_current_command appears to give different answers from tmux for the same scenario.

1. Idle shells report as shell, not the shell binary name

Reproducer (PowerShell):

psmux new-session -d -s repro
# Sit at an idle pwsh prompt — no foreground program running.
psmux list-panes -t 'repro:1' -F '#{pane_current_command}'

Observed: shell
Expected (matching tmux): pwsh

The literal string "shell" comes from src/format.rs:

"pane_current_command" => {
    if let Some(p) = target_pane() {
        if let Some(pid) = p.child_pid {
            crate::platform::process_info::get_foreground_process_name(pid)
                .unwrap_or_else(|| "shell".into())
        }
        ...
    }
}

When get_foreground_process_name returns None (no descendants found), the format layer substitutes the placeholder string "shell" rather than looking up the pane shell PID's own executable basename.

For comparison, tmux's osdep-linux.c:

char *osdep_get_name(int fd, __unused char *tty) {
    pid_t pgrp;
    if ((pgrp = tcgetpgrp(fd)) == -1) return NULL;
    xasprintf(&path, "/proc/%lld/cmdline", (long long) pgrp);
    /* reads /proc/<pgrp>/cmdline */
}

For an idle pwsh, pwsh is the only process attached to the TTY, so tcgetpgrp returns pwsh's pgrp, /proc/<pid>/cmdline returns pwsh, and pane_current_command is pwsh. tmux never has to substitute a placeholder.

2. Active programs report a deep descendant, not the foreground process

Reproducer:

psmux new-session -d -s repro
# Launch a long-running node child (clean process tree)
psmux send-keys -t 'repro:1' 'node -e "setInterval(()=>{},1000)"' Enter
Start-Sleep -Seconds 6
psmux list-panes -t 'repro:1' -F '#{pane_current_command}'

Observed for node: node ✓ (works in this clean case)

Now try a program that spawns a subprocess tree:

psmux send-keys -t 'repro:1' 'copilot' Enter
Start-Sleep -Seconds 10
psmux list-panes -t 'repro:1' -F '#{pane_current_command}'

Observed: cmd (or aspire, dotnet, depending on which descendant was deepest at sample time)
Expected (matching tmux): copilot

The OS process tree at the moment of measurement showed pwsh.exe with copilot.exe as its only direct child. There was no cmd.exe visible as a child of either at that instant. But pane_current_command returned cmd, suggesting the tree traversal caught a transient cmd.exe descendant.

The implementation in src/platform.rs:

/// Strategy: BFS all descendants, then pick the deepest non-system leaf.
/// When multiple candidates exist at the same depth, prefer the largest
/// PID (heuristic for "most recently created").
fn find_foreground_child_pid(root_pid: u32) -> Option<u32> { ... }

The strategy is deliberately "deepest non-system leaf." That works for trivial cases (node with no children, bash -c 'foo' where foo is the meaningful thing), but breaks for any program that spawns its own subprocess hierarchy as part of normal operation:

  • copilot spawns cmd / aspire / agency / dotnet
  • nvim with an LSP server has the language server as a deeper leaf
  • Anything with worker processes (pm2, parcel, webpack dev servers, etc.)

Worse, the result is non-deterministic across samples because the deepest leaf changes as transient subprocesses come and go.

tmux's mechanism is precise rather than heuristic: tcgetpgrp(fd) returns the single process group that owns the TTY's foreground (typically the immediate child of the shell that called tcsetpgrp on startup). Subprocesses inherit the parent's process group but do NOT become the foreground unless they explicitly call tcsetpgrp. So pane_current_command reliably returns whichever program took TTY control, regardless of how many subprocesses it spawns underneath.

There is no equivalent primitive on Windows. ConPTY does not expose foreground process group; GetConsoleProcessList is explicitly deprecated by MSDN:

This API is not recommended and does not have a virtual terminal equivalent. This decision intentionally aligns the Windows platform with other operating systems.

Microsoft has stopped extending Win32 console for this category of question precisely because they want VT-sequence-based shell↔terminal coordination going forward. The path forward, then, is the same one the modern terminal ecosystem has converged on: parse what the shell volunteers via OSC sequences, fall back to a process-tree heuristic when no integration is present.

Why this matters

pane_current_command is the canonical "what's running here" identifier across the broader psmux plugin ecosystem, and the divergence breaks features that depend on it being predictable.

  • Blocks psmux/psmux-plugins#22 (per-program restore strategies for psmux-resurrect, accepted in principle, prototype ready). That proposal keys strategies on pane_current_command e.g., @resurrect-strategy-copilot 'project' fires whenever a saved pane reports copilot as its command. With the current heuristic, a real copilot pane saves as cmd / aspire / dotnet / whichever descendant won the race, rarely copilot. The strategy mechanism's matching key is fundamentally non-deterministic, so no user-written strategy can ever reliably fire. Aligning pane_current_command with tmux's effective behavior is the precondition for it's good , but exit of last window(fast if not last) is quite slow #22 to be usable.
  • Window auto-rename picks misleading names; windows running copilot get renamed to cmd or dotnet or aspire depending on which descendant was foreground at sample time, instead of the program the user actually launched.
  • Status-bar #{pane_current_command} substitution displays the same misleading name in the status line, with the additional pathology that the displayed name changes spontaneously as transient subprocesses come and go.
  • Window titles via automatic-rename-format inherit the same problem.

How the modern terminal ecosystem solves this

I surveyed how every relevant tool in the multiplexer / terminal / IDE space handles "what command is running in this session." Three emit patterns have emerged for carrying the command name:

Pattern Emitter Sequence Notes
A Kitty (bash/zsh) \e]133;C;cmdline=<%q-quoted cmd>\a Extends FinalTerm OSC 133;C with a cmdline= parameter. Co-located with the state marker; backward-compatible (terminals that don't parse the param see bare 133;C).
A' Kitty (fish) \e]133;C;cmdline_url=<url-escaped cmd>\a Same shape, URL-escaping for fish's quoting style.
B WezTerm (wezterm.sh, bash/zsh) \e]1337;SetUserVar=WEZTERM_PROG=<b64>\a Piggybacks on iTerm2's OSC 1337 user-vars mechanism with a namespaced var.
C VS Code (shellIntegration.ps1) \e]633;E;<escaped cmd>;<nonce>\a Invents a new OSC number entirely. VS-Code-only — no other emitter, no other consumer.

Pattern A is the cleanest design: no new OSC number, no namespace squatting, no separate sequence-ordering issues, smallest byte overhead. It's published in kitty's shell-integration docs and emitted verbatim by kitty's bash/zsh/fish integration scripts (see kitty.bashprintf "\e]133;C;cmdline=%q\a" "$last_cmd").

Pattern B has user-base value: wezterm.sh is widely sourced and emits WEZTERM_PROG from a preexec hook today. Parsing it gives psmux instant compat with that user base — without psmux defining its own var name (which would have the same namespace concern WezTerm's name has).

Pattern C has narrower value: VS Code-script users get the rich path, nobody else does.

Other tools in the survey:

  • iTerm2 emits OSC 133;C (no parameter) and OSC 1337;SetUserVar=... (generic mechanism) but does NOT emit a "current command" var — they derive command identity from tcgetpgrp (Mac-only). They invented the SetUserVar mechanism but didn't standardize a command var; WezTerm did that downstream.
  • Ghostty emits OSC 133;C\a (no parameter) and copies kitty's kitty-shell-cwd:// URL scheme for OSC 7. Gets command identity from its own scrollback parsing.
  • Starship, oh-my-posh, powerlevel10k emit state markers (OSC 7, OSC 133 A/B/C/D) when configured but don't emit command-carrying sequences today. OMP is the closest fit for pwsh — it already wires Set-PSReadLineKeyHandler -Key Enter to emit OSC 133;C when shell_integration: true is enabled. Adding cmdline_url= to that existing emission is a ~4-line patch I plan to upstream as a separate FR.

The legacy approach — tcgetpgrp + /proc/<pid>/cmdline walking — is what tmux and screen do. It works on Unix, breaks on Windows (this issue), and is brittle even on Unix once wrappers nest deeply. Every modern tool has chosen shell-integration-via-escapes instead.

Two more OSC sequences are independent of command identity but compose with it

OSC Origin Carries Used by
OSC 7 dtterm/xterm cwd as file://HOST/PATH Universal: Windows Terminal, WezTerm, iTerm2, VS Code, Konsole, gnome-terminal, Kitty, Alacritty, ghostty (with kitty-shell-cwd:// flavor)
OSC 133 A/B/D FinalTerm (FTCS) prompt/output/done state markers + exit code Broad: Windows Terminal, WezTerm, Alacritty, Kitty, iTerm2, VS Code, ghostty

OSC 7 is the right primitive for per-pane cwd. OSC 133 A/B/D are state markers — they let psmux know when the shell is idle vs. running a command. Both compose cleanly with the command-identity layer and unlock significant value beyond just pane_current_command:

  • psmux-resurrect cwd-aware restore comes for free once OSC 7 is parsed per-pane.
  • monitor-activity could finally distinguish "command is running" from "command finished," replacing the current "any byte arrived" heuristic that's nearly unusable for streaming AI tools.

Empirical verification

I wrote a minimal pwsh shell-integration emitter following kitty's pattern (~25 lines, no VS Code dependency) and captured what reaches the terminal. Annotated bytes (\e = ESC, \a = BEL):

\e]7;file://MATTKOT-SURFACE/C:/Projects/dotfiles\a       ← OSC 7 (cwd)
\e]133;A\a                                                 ← prompt start
PS C:/Projects/dotfiles>                                   ← (visible prompt)
\e]133;B\a                                                 ← prompt end
\e]133;C;cmdline_url=copilot%20--yolo\a                   ← kitty pattern: cmd carried inline with state marker
<command output here>
\e]133;D;0\a                                               ← done, exit code 0

\e]7;file://MATTKOT-SURFACE/C:/Projects/dotfiles\a
\e]133;A\a
PS C:/Projects/dotfiles> \e]133;B\a
\e]133;C;cmdline_url=dotnet%20build%20-c%20Release\a

Round-trip:

  • copilot%20--yolocopilot --yolo (flags preserved)
  • dotnet%20build%20-c%20Releasedotnet build -c Release

The emission is via Set-PSReadLineKeyHandler -Key Enter wrapping AcceptLine: capture the command from PSReadLine's buffer state BEFORE AcceptLine, call AcceptLine, then emit OSC 133;C;cmdline_url=.... The same hook OMP uses for its existing OSC 133;C emission, just extended with the kitty parameter.


Proposed resolution: a layered cascade

Replace the original Option A / Option B framing with a layered cascade. Higher layers are more authoritative; psmux falls through when the higher ones produce no signal. Layers 1-3 are shell-integration sources; layers 4-5 are the existing heuristic, improved.

Command identity (what is running in this pane), ordered from most preferred to fallback:

Priority Layer Source Notes
1 OSC 133;C;cmdline= / cmdline_url= kitty's published pattern Primary. Co-located with the state marker. No namespace concern.
2 OSC 1337 ; SetUserVar = WEZTERM_PROG = b64(command) WezTerm's wezterm.sh Opportunistic compat. psmux consumes WezTerm's namespaced var — doesn't squat on it. Zero work for existing wezterm.sh users.
3 OSC 633 ; E ; <command> VS Code's shellIntegration.ps1 Last-resort. Same data as 1/2 when present; recognized so VS Code-script users get the rich path.
4 Immediate-child heuristic process tree Replaces today's deepest-leaf strategy. Matches tmux's effective behavior in the common case (program-launched-from-shell, where the program took TTY foreground and subprocesses inherit its group).
5 Shell binary basename pane shell PID Fallback when no descendants exist (idle shell case from divergence #1). Replaces the literal "shell" placeholder.

Layer 4 includes a brief skip-list mechanism for known wrappers (npx, cmd /c, bash -c) where the meaningful thing is one level deeper; for those specific cases the heuristic falls through to the next child.

What the process-tree fix (layers 4 + 5) actually does

These two layers stand alone — they're the original Option A from this issue, re-cast as the fallback in the cascade. If the OSC work doesn't ship, just landing layers 4 + 5 closes the most painful symptom (the copilot case) for everyone, no shell integration required.

Concretely:

  • Layer 4: find_foreground_child_pid (in src/platform.rs) changes from "deepest non-system leaf" to "immediate non-system child" of the pane shell PID. This matches tmux's effective behavior in the common case: a program launched from the shell takes TTY foreground via tcsetpgrp on startup; any subprocesses it spawns inherit its process group and don't become foreground unless they themselves call tcsetpgrp. So picking the immediate child is correct for copilot, nvim (with or without LSP children), pm2, dev servers, and anything else that fans out workers under a single foreground process.

    Tradeoff: for shell-style wrappers (bash -c 'foo', cmd /c 'foo', npx <tool>), the meaningful program is one level deeper than the immediate child. The skip-list above handles the named common cases; users running other wrappers fall back to layer 5 or shell-integration if enabled.

  • Layer 5: format-layer fix (src/format.rs line 1144). When find_foreground_child_pid returns None, instead of substituting the literal "shell" placeholder, look up the pane shell PID's own executable basename. Idle pwsh sessions report pwsh, idle bash sessions report bash, matching tmux.

Both are small, surgical changes — no new options, no new API surface — and together they close divergences #1 and #2 even without any OSC parsing. The OSC layers (1-3) are strictly additive on top.

Independent tracks (always parsed when emitted, surfaced as separate format tokens):

Layer Source What it gives
OSC 7 parser shell integration per-pane cwd. Enables psmux-resurrect cwd-aware restore.
OSC 133 ; A/B/D state machine shell integration "command is running" state + exit code. Foundation for a better monitor-activity.

State machine for the integrated layers:

state = Idle
on OSC 133;A                                  → state = Idle
on OSC 1337;SetUserVar=WEZTERM_PROG=<b64>     → pending_cmd = b64_decode(<b64>); source = "wezterm"
on OSC 633;E;<cmd>[;<nonce>]                  → if pending_cmd.is_none(): pending_cmd = <cmd>; source = "vscode"
on OSC 133;C[;cmdline=<q-quoted>]             → state = Running(cmd_from_param or pending_cmd, source); pending_cmd = None
on OSC 133;C[;cmdline_url=<url-escaped>]      → state = Running(cmd_from_param or pending_cmd, source); pending_cmd = None
on OSC 133;C                                  → state = Running(pending_cmd, source); pending_cmd = None
on OSC 133;D[;<exit>]                         → state = Idle
on OSC 7;file://HOST/<path>                   → cwd = <path>  (independent track, always)

(Note: 133;C's optional cmdline= / cmdline_url= parameter takes precedence over any pending_cmd from a separate sequence — it's co-located with the state transition, no ordering ambiguity.)

pane_current_command resolution at query time:

  • Running(Some(cmd), _) → return cmd
  • Running(None, _) → fall through to layer 4 (heuristic)
  • Idle → fall through to layer 5 (shell basename)
  • No integration state at all → fall through to layer 4

Why this shape

  • No vendor protocol from psmux. Every parsed sequence already exists in the wild; psmux is strictly a consumer.
  • No psmux-defined SetUserVar variable name. The kitty pattern sidesteps the namespace question entirely — cmdline= is a generic parameter on a generic state marker.
  • Degrades gracefully. Users without any shell integration get layers 4-5 — same behavior as a process-tree-only fix.
  • Same parser, three pieces of metadata. Command, cwd, and state markers all flow through one OSC parser hooked into the existing PTY output handling. Small one-time cost; multiple downstream wins.
  • Plugins benefit transitively. psmux-resurrect (see psmux-plugins#22) gets richer save data: command literal + cwd, instead of inferred process name. The resurrect-strategy contract could expose provenance ("shell told us authoritatively" vs. "we guessed") so plugins can adapt.

Considered alternatives

  • GetConsoleProcessList() + heuristics about console allocation order. Same FIFO attach order we already get from the process tree, no additional information. Microsoft's docs explicitly recommend against this API for foreground detection.
  • A psmux-namespaced OSC 1337 SetUserVar variable (e.g., PSMUX_CMD or cmd). Considered and rejected — puts the same namespace burden on every future consumer that the WezTerm-naming concern raises. The kitty parameter pattern avoids this.
  • A pluggable script-based strategy system. pane_current_command is read on every status bar refresh, potentially many times per second across all panes. The latency budget is microseconds; spawning a PowerShell process per query is two orders of magnitude too slow.
  • Job objects. Group processes but don't have TTY semantics; doesn't tell us "which one has foreground."
  • Leave it alone and patch around it in plugins. Pushes the workaround into every plugin that consumes the variable, and the format-string layer (#{pane_current_command} in status bars) still produces misleading output. Better fixed at the source.

Enabling shell integration in practice (downstream docs concern)

Setups that provide the integrated data psmux would parse:

Setup OSC 7 (cwd) OSC 133 state Command identity Notes
kitty's bash/zsh integration (kitty.bash / kitty.zsh) ✅ (kitty URL) ✅ via cmdline= / cmdline_url= The reference implementation.
oh-my-posh pwd: osc7 + shell_integration: true ❌ today, planned FR upstream to add cmdline_url= The closest existing pwsh integration.
WezTerm's wezterm.sh (bash/zsh) ✅ via SetUserVar WEZTERM_PROG Existing in the wild; covered by layer 2.
VS Code's shellIntegration.ps1 ❌ (emits 633 only) ❌ (emits 633 only) ✅ via OSC 633;E Layer 3 of the cascade.
Standalone pwsh snippet (~25 lines) ✅ via cmdline_url= For pwsh users without OMP. Becomes unnecessary once OMP ships the FR.

OMP FR shape (will cross-link once filed): in OMP's existing New-EnterKeyHandler (which today emits bare OSC 133;C for _ompFTCSMarks), capture the command via [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState before calling AcceptLine, then emit OSC 133;C;cmdline_url=<url-escaped> instead. Backward compatible (terminals that don't parse the parameter see bare 133;C), aligned with kitty's published spec, gated by the existing shell_integration flag.

psmux itself should not ship an integration script — document the opt-in; point at kitty / OMP / WezTerm as the upstream sources.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions