Compare commits

...

10 Commits

Author SHA1 Message Date
YeonGyu-Kim
3b5bafc847 docs(roadmap): add #332 — doctor json missing top-level status field 2026-04-29 22:04:07 +09:00
Bellman
e7074f47ee Merge pull request #2838 from ultraworkers/docs/roadmap-322-323-clean
docs(roadmap): add #322 #323 — json stream corruption and session identity contradiction
2026-04-29 19:40:50 +09:00
YeonGyu-Kim
9468383b67 docs(roadmap): add #322 #323 — json stream corruption and session identity contradiction 2026-04-29 19:38:00 +09:00
Bellman
1da2781816 Merge pull request #2835 from ultraworkers/docs/roadmap-249-issue-github-oauth-opacity
docs(roadmap): add #249 issue GitHub OAuth opacity pinpoint
2026-04-29 19:31:50 +09:00
Yeachan-Heo
9037430d52 docs(roadmap): add #249 issue github oauth opacity pinpoint 2026-04-29 10:01:16 +00:00
Bellman
8e22f757d8 Merge pull request #2834 from ultraworkers/docs/roadmap-248-prompt-mode-silent-hang
docs(roadmap): add #248 prompt-mode silent-hang pinpoint
2026-04-29 18:31:48 +09:00
Yeachan-Heo
7676b376ae docs(roadmap): add #248 prompt-mode silent-hang pinpoint 2026-04-29 08:24:37 +00:00
Sigrid Jin (ง'̀-'́)ง oO
1011a83823 Merge pull request #2829 from ultraworkers/fix/issue-320-session-lifecycle-classification
Fix session lifecycle classification for idle tmux shells
2026-04-29 16:11:58 +09:00
Yeachan-Heo
1376d92064 Filter stub commands from resume-safe help
Keep claw --help's resume-safe slash command summary aligned with the interactive command list by filtering STUB_COMMANDS and adding regression coverage.
2026-04-29 03:31:34 +00:00
Yeachan-Heo
be53e04671 Classify saved sessions by live work rather than pane existence
Operator status previously treated any tmux pane in a workspace as equivalent to active work. The new classifier uses tmux pane command/path metadata as a soft signal, treats plain shells as idle, and adds dirty-worktree abandoned markers to status and session-list output for clawhip consumers.

Constraint: Keep issue #320 prototype minimal and additive without new dependencies

Rejected: Screen-scraping pane output | fragile and broader than needed for lifecycle classification

Confidence: high

Scope-risk: narrow

Tested: cargo test -p rusty-claude-cli

Tested: cargo check -p rusty-claude-cli

Not-tested: cargo clippy -p rusty-claude-cli --all-targets -- -D warnings is blocked by pre-existing commands crate clippy::unnecessary_wraps warnings
2026-04-28 13:12:37 +00:00
3 changed files with 421 additions and 17 deletions

View File

@@ -2128,7 +2128,7 @@ Original filing (2026-04-13): user requested a `-acp` parameter to support ACP p
**Source.** Jobdori dogfood 2026-04-18 against `/tmp/cdJ` on main HEAD `b7539e6` in response to Clawhip pinpoint nudge at `1494744278423961742`. Adjacent to #85 (skill discovery ancestor walk) on the *discovery* side — #85 is "skills are discovered too broadly," #95 is "skills are *installed* too broadly." Together they bound the skill-surface trust problem from both the read and the write axes. Distinct sub-cluster from the permission-audit bundle (#50 / #87 / #91 / #94) and from the truth-audit cluster (#80#87, #89): this is specifically about *scope asymmetry between install and settings* and the *missing uninstall verb*.
96. **`claw --help`'s "Resume-safe commands:" one-liner summary does not filter `STUB_COMMANDS` — 62 documented slash commands that are explicitly marked unimplemented still show up as valid resume-safe entries, contradicting the main Interactive slash commands list just above it (which *does* filter stubs per ROADMAP #39)** — dogfooded 2026-04-18 on main HEAD `8db8e49` from `/tmp/cdK`. The `render_help` output emits two separate enumerations of slash commands; only one of them applies the stub filter. The Resume-safe summary advertises `/budget`, `/rate-limit`, `/metrics`, `/diagnostics`, `/bookmarks`, `/workspace`, `/reasoning`, `/changelog`, `/vim`, `/summary`, `/brief`, `/advisor`, `/stickers`, `/insights`, `/thinkback`, `/keybindings`, `/privacy-settings`, `/output-style`, `/allowed-tools`, `/tool-details`, `/language`, `/max-tokens`, `/temperature`, `/system-prompt` — all of which are explicitly in `STUB_COMMANDS` with "Did you mean" guards and no parse arm.
96. **`claw --help`'s "Resume-safe commands:" one-liner summary does not filter `STUB_COMMANDS` — 62 documented slash commands that are explicitly marked unimplemented still show up as valid resume-safe entries, contradicting the main Interactive slash commands list just above it (which *does* filter stubs per ROADMAP #39)** — **done (verified 2026-04-29):** the Resume-safe command summary now applies the same `STUB_COMMANDS` filter as the Interactive slash command block before rendering help, so unimplemented slash-command stubs no longer advertise as resume-safe. Added `stub_commands_absent_from_resume_safe_help` to lock the filtered one-liner contract alongside the existing REPL completion filter. Fresh proof: `cargo fmt --all --check`, `cargo test -p rusty-claude-cli stub_commands_absent_from_resume_safe_help -- --nocapture`, and `cargo test -p rusty-claude-cli parses_direct_cli_actions -- --nocapture` pass. Original filing below for traceability.
**Concrete repro.**
```
@@ -6253,4 +6253,13 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
246. **Dogfood reminder cron can self-fail by timing out during active cycles, so the nudge loop itself is not trustworthy as an observability surface** — dogfooded 2026-04-21 in `#clawcode-building-in-public` after multiple consecutive alerts: `Cron job "clawcode-dogfood-cycle-reminder" failed: cron: job execution timed out` at 14:14, 14:24, 14:34, 14:44, 15:13, and 15:23 KST while the same dogfood cycle was actively producing reports and fixes. This is not just scheduler noise — it is a clawability gap in the reminder/control loop itself. A downstream claw seeing both repeated dogfood nudges and repeated cron timeouts cannot tell whether the reminder actually delivered, partially delivered, duplicated, or died after side effects. **Required fix shape:** (a) classify reminder execution outcome explicitly (`delivered`, `timed_out_after_send`, `timed_out_before_send`, `suppressed_as_duplicate`, `skipped_due_to_active_cycle`) instead of a single generic timeout; (b) attach the target message/report cycle id and whether a Discord post was already emitted before timeout; (c) add a fast-path/no-op path when the cycle state is unchanged or an active report is already in flight so the reminder job can exit cleanly instead of hanging; (d) add regression coverage proving repeated unchanged-state cycles do not stack timeouts or duplicate nudges. **Why this matters:** if the reminder loop itself is ambiguous, claws waste time responding to scheduler artifacts instead of real product state, and the dogfood surface stops being a reliable source of truth. Source: live clawhip/Jobdori dogfood cycle on 2026-04-21 with repeated timeout alerts in `#clawcode-building-in-public`.
247. **MCP memory permission prompts can recur after a transport failure, leaving an active worker blocked in a second consent loop instead of a typed degraded state** — dogfooded 2026-04-27 from live session `clawcode-human` while responding to the claw-code dogfood nudge. The session first asked permission for `omx_memory.project_memory_read`; after approval, the call failed with `Transport closed`, then the runtime immediately attempted `omx_memory.notepad_read` and blocked again on a fresh allow prompt. From the outside this looks like an automation-hostile MCP lifecycle gap: the worker is neither cleanly ready nor cleanly failed, and downstream claws must scrape the pane to learn that memory MCP is both consent-gated and transport-degraded. **Required fix shape:** (a) after an MCP transport closes, emit a typed degraded state such as `mcp_transport_closed` with server/tool identity; (b) suppress or batch follow-up permission prompts for the same failed MCP server until transport recovery is proven; (c) expose whether the task can continue without that MCP tool or is blocked on memory; (d) add regression coverage for `permission granted -> transport closed -> follow-up tool attempt` so it becomes one structured blocker instead of repeated interactive consent loops. **Why this matters:** MCP memory should either be available, explicitly degraded, or explicitly blocked; repeated permission prompts after a closed transport make prompt delivery and readiness ambiguous. Source: live `clawcode-human` pane on 2026-04-27 04:3x UTC.
247. **MCP memory permission prompts can recur after a transport failure, leaving an active worker blocked in a second consent loop instead of a typed degraded state** — dogfooded 2026-04-27 from live session `clawcode-human` while responding to the claw-code dogfood nudge. The session first asked permission for `omx_memory.project_memory_read`; after approval, the call failed with `Transport closed`, then the runtime immediately attempted `omx_memory.notepad_read` and blocked again on a fresh allow prompt. From the outside this looks like an automation-hostile MCP lifecycle gap: the worker is neither cleanly ready nor cleanly failed, and downstream claws must scrape the pane to learn that memory MCP is both consent-gated and transport-degraded. **Required fix shape:** (a) after an MCP transport closes, emit a typed degraded state such as `mcp_transport_closed` with server/tool identity; (b) suppress or batch follow-up permission prompts for the same failed MCP server until transport recovery is proven; (c) expose whether the task can continue without that MCP tool or is blocked on memory; (d) add regression coverage for `permission granted -> transport closed -> follow-up tool attempt` so it becomes one structured blocker instead of repeated interactive consent loops. **Why this matters:** MCP memory should either be available, explicitly degraded, or explicitly blocked; repeated permission prompts after a closed transport make prompt delivery and readiness ambiguous. Source: live `clawcode-human` pane on 2026-04-27 04:3x UTC. **Fresh-run follow-up 2026-04-29:** owner-requested live session `claw-code-issue-247-human-fresh-run` used the actual `./rust/target/debug/claw` binary; `doctor` and `status` were green, so the remaining Phase-0 fresh-run evidence moved from MCP consent-loop reproduction to the non-interactive prompt silent-hang captured separately as #248.
248. **Non-interactive prompt mode can exceed caller timeouts with no in-band startup/API phase event or partial status artifact** — dogfooded 2026-04-29 from live tmux session `claw-code-issue-247-human-fresh-run` after the owner explicitly asked gaebal-gajae to make a fresh session and use `claw-code` directly. The actual `./rust/target/debug/claw` binary was launched via `clawhip tmux new` on current main. `claw doctor --output-format json` and `claw status --output-format json` both succeeded and reported auth/config/workspace ok, but minimal non-interactive prompt calls (`timeout 120 ./rust/target/debug/claw --output-format json --dangerously-skip-permissions "echo hello"` and `timeout 120 ./rust/target/debug/claw --output-format json prompt "Reply with just the word hello"`) both timed out from the outer harness after roughly 150s with only `Command exceeded timeout` visible. There was no machine-readable `api_request_started`, `waiting_for_first_token`, provider/model/base-url identity, retry count, or partial status file/event that would let clawhip distinguish slow provider, network stall, auth/OAuth drift, stream parser hang, or prompt-mode bug. **Required fix shape:** (a) emit structured non-interactive lifecycle events for `startup_ok`, `api_request_started`, `first_byte/first_token`, retry/backoff, and terminal `timeout_or_stall` states; (b) include provider/model/base URL source and auth source category without leaking secrets; (c) support a CLI/request timeout flag or env override that returns a typed JSON error before the outer orchestrator kills the process; (d) write/emit a final partial status artifact on timeout so lane monitors do not have to infer state from a dead process. **Why this matters:** non-interactive prompt mode is the automation path; if it can hang past the caller's timeout while doctor/status are green, claws lose the ability to tell whether startup, auth, transport, provider latency, or stream consumption failed. Source: live session `claw-code-issue-247-human-fresh-run` on 2026-04-29.
249. **`/issue` advertises GitHub issue creation but never reaches a GitHub/OAuth/auth preflight or creation path, and the non-interactive error suggests unusable resume forms** — dogfooded 2026-04-29 on current main `8e22f757` while chasing the remaining Phase-0 GitHub OAuth blocker. The visible help advertises `/issue [context]` as “Draft or create a GitHub issue from the conversation,” but the actual implementation path only renders a local `Issue` report (`format_issue_report`) and does not invoke `gh`, GitHub API, OAuth, token discovery, browser auth, or even a dry-run/auth-preflight surface. Direct non-interactive use (`./rust/target/debug/claw '/issue dogfood test'`) returns `slash command /issue dogfood test is interactive-only` and suggests `claw --resume SESSION.jsonl /issue ...` / `claw --resume latest /issue ...` “when the command is marked [resume]”, while `/help` does not mark `/issue` as resume-safe and resume dispatch rejects interactive-only commands. That leaves operators with a GitHub-labeled command whose real behavior is neither issue creation nor a clear GitHub OAuth blocker. **Required fix shape:** (a) split the contract explicitly: either rename/copy to “draft issue text” or implement a real `create` path with GitHub auth preflight; (b) surface a machine-readable GitHub auth state (`gh_cli_authenticated`, `github_token_present`, `oauth_required`, `creation_unavailable`) before any issue-create attempt; (c) make the direct-mode error avoid suggesting resume forms for commands not marked resume-safe; (d) add regression coverage proving `/issue` help, direct-mode rejection, resume support flags, and creation/draft behavior agree. **Why this matters:** Phase-0 GitHub OAuth verification cannot complete if the only GitHub issue surface stops at local prose while still advertising creation. Claws need to know whether they are missing GitHub auth, using a draft-only helper, or hitting an unimplemented creation path. Source: gaebal-gajae dogfood cycle in `#clawcode-building-in-public` on 2026-04-29.
322. **Config deprecation warnings are emitted to stderr even under `--output-format json`, making JSON output unparseable from combined stdout+stderr capture** — dogfooded 2026-04-29 by Jobdori on current main (`8e22f75`). Running `cargo run --bin claw -- doctor --output-format json 2>&1 | python3 -c "import sys,json; json.loads(sys.stdin.read())"` fails with `Expecting value: line 1 column 1 (char 0)` because a `warning: /path/settings.json: field "enabledPlugins" is deprecated. Use "plugins.enabled" instead` line is emitted to stderr before the JSON body begins. When a caller captures combined output (the common automation pattern: `2>&1`, subprocess `STDOUT | STDERR`, PTY capture, or tmux pane scrape) the warning prefix breaks JSON parse for every downstream consumer. Root cause: `rust/crates/runtime/src/config.rs` line ~300 calls `eprintln!("warning: {warning}")` unconditionally during `ClawSettings::load_merged()` regardless of active output format. **Required fix shape:** (a) thread the active `CliOutputFormat` through the config loading path and suppress or defer human-readable warning strings when `json` mode is active; (b) instead, collect deprecation diagnostics and inject them into the JSON output as a top-level `"warnings": [...]` array (same field already used by `doctor`); (c) ensure the JSON body is always the first bytes on stdout and all prose warnings stay on stderr or are suppressed in json mode; (d) add regression coverage proving `claw <any-cmd> --output-format json` stdout is valid JSON regardless of config deprecation state. **Why this matters:** `--output-format json` is the automation/claw contract; if config warnings can silently corrupt the JSON stream, every orchestration layer that captures combined output gets broken parse-on-warning with no stable fallback. Source: Jobdori live dogfood on mengmotaHost, claw-code main `8e22f75`, 2026-04-29.
323. **`status --output-format json` reports `session.session = "live-repl"` while simultaneously reporting `session_lifecycle.kind = "saved_only"` — contradictory session identity in a single status snapshot** — dogfooded 2026-04-29 by Jobdori on current main (`804d96b`). Running `claw status --output-format json` from an active REPL-style invocation produced `"session": "live-repl"` in the `workspace` block and `"session_lifecycle": {"kind": "saved_only", "pane_id": null, ...}` in the same object. Those two fields carry contradictory claims: `"live-repl"` asserts there is an active interactive session, while `"saved_only"` asserts there is no live tmux pane hosting the session — the session exists only as a saved artifact. A downstream claw reading this snapshot cannot tell which claim to trust: is this a running session whose pane is undetectable, or a saved-only session that the `session` field is misclassifying? Root cause: `"live-repl"` is a fallback sentinel emitted by `main.rs:6070` when `context.session_path` is `None`, while `session_lifecycle` is computed independently by `classify_session_lifecycle_for()` from tmux pane discovery; the two fields share no common source and can diverge. **Required fix shape:** (a) derive both `session.session` and `session_lifecycle.kind` from the same lifecycle classification result so they cannot diverge; (b) replace the `"live-repl"` free-form sentinel with a structured `session_kind` field (`live_repl`, `saved`, `resume`, etc.) that carries the same type vocabulary as `session_lifecycle.kind`; (c) when `session_lifecycle.kind = "saved_only"`, never emit `"session": "live-repl"` (or vice versa); (d) add a regression test proving `status --output-format json` never emits `session.kind = "live_repl"` and `session_lifecycle.kind = "saved_only"` simultaneously. **Why this matters:** `status --output-format json` is the machine-readable truth surface for session state; if two fields in the same snapshot contradict each other, every lane, monitor, and orchestrator has to pick a winner instead of reading a coherent state. Source: Jobdori live dogfood on mengmotaHost, claw-code `804d96b`, 2026-04-29.
332. **`doctor --output-format json` has no top-level `status` field, breaking the `result["status"] == "ok"` check pattern that works for all other JSON commands** — dogfooded 2026-04-29 by Jobdori on current main (`e7074f4`). Running `claw doctor --output-format json | python3 -c "import sys,json; print(json.load(sys.stdin).get('status'))"` prints `None`. The doctor JSON uses `has_failures` (bool) + `summary: {failures, ok, total, warnings}` to express aggregate health, while every other JSON command (`status`, `sandbox`, `stats`, `cost`, etc.) uses a top-level `"status": "ok"|"warn"|"error"` field. A downstream lane checking `result.get("status") == "ok"` will silently misread a doctor output with 2 warnings as if no status were present, instead of getting `"warn"`. **Required fix shape:** (a) add a top-level `"status": "ok" | "warn" | "error"` field to doctor JSON output derived from the same `has_failures`/warning-count logic already present; (b) ensure the field is always present and uses the same vocabulary as other JSON commands; (c) keep `has_failures` and `summary` for backward compat but document `status` as the canonical machine-readable aggregate verdict; (d) add regression coverage proving `claw doctor --output-format json` always includes a non-null `"status"` field. **Why this matters:** `status` is the idiomatic machine-readable health verdict in claw's JSON surface; when `doctor` skips it, automation layers that unify health checks across commands (`doctor` + `status` + `sandbox`) must special-case doctor or silently miss warnings. Source: Jobdori live dogfood on mengmotaHost, claw-code `e7074f4`, 2026-04-29.

View File

@@ -365,3 +365,14 @@ US-021 COMPLETED (Request body size pre-flight check - from dogfood findings)
- Tests: 5 new tests for size estimation and limit checking
PROJECT STATUS: COMPLETE (21/21 stories)
Iteration 2026-04-29 - ROADMAP #96 COMPLETED
------------------------------------------------
- Pulled origin/main: already up to date.
- Selected ROADMAP #96 as a small repo-local Immediate Backlog item: the `claw --help` Resume-safe command summary leaked slash-command stubs despite the main Interactive command listing filtering them.
- Files: rust/crates/rusty-claude-cli/src/main.rs, ROADMAP.md, progress.txt.
- Changed help rendering to filter `resume_supported_slash_commands()` through `STUB_COMMANDS` before building the Resume-safe one-liner.
- Added `stub_commands_absent_from_resume_safe_help` regression coverage so future stub additions cannot leak into the Resume-safe summary.
- Targeted verification: `cargo test -p rusty-claude-cli stub_commands_absent_from_resume_safe_help -- --nocapture` passed; `cargo test -p rusty-claude-cli parses_direct_cli_actions -- --nocapture` passed.
- Format/check verification: `cargo fmt --all --check`, `git diff --check`, and `cargo check -p rusty-claude-cli` passed.
- Broader clippy note: `cargo clippy -p rusty-claude-cli --all-targets -- -D warnings` is blocked by pre-existing `clippy::unnecessary_wraps` failures in `rust/crates/commands/src/lib.rs` (`render_mcp_report_for`, `render_mcp_report_json_for`), outside this diff.

View File

@@ -1961,6 +1961,7 @@ fn render_doctor_report() -> Result<DoctorReport, Box<dyn std::error::Error>> {
project_root,
git_branch,
git_summary,
session_lifecycle: classify_session_lifecycle_for(&cwd),
sandbox_status: resolve_sandbox_status(sandbox_config.sandbox(), &cwd),
// Doctor path has its own config check; StatusContext here is only
// fed into health renderers that don't read config_load_error.
@@ -2805,6 +2806,7 @@ struct StatusContext {
project_root: Option<PathBuf>,
git_branch: Option<String>,
git_summary: GitWorkspaceSummary,
session_lifecycle: SessionLifecycleSummary,
sandbox_status: runtime::SandboxStatus,
/// #143: when `.claw.json` (or another loaded config file) fails to parse,
/// we capture the parse error here and still populate every field that
@@ -2834,6 +2836,75 @@ struct GitWorkspaceSummary {
conflicted_files: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SessionLifecycleKind {
RunningProcess,
IdleShell,
SavedOnly,
}
impl SessionLifecycleKind {
fn as_str(self) -> &'static str {
match self {
Self::RunningProcess => "running_process",
Self::IdleShell => "idle_shell",
Self::SavedOnly => "saved_only",
}
}
fn human_label(self) -> &'static str {
match self {
Self::RunningProcess => "running process",
Self::IdleShell => "idle shell",
Self::SavedOnly => "saved only",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SessionLifecycleSummary {
kind: SessionLifecycleKind,
pane_id: Option<String>,
pane_command: Option<String>,
pane_path: Option<PathBuf>,
workspace_dirty: bool,
abandoned: bool,
}
impl SessionLifecycleSummary {
fn signal(&self) -> String {
let mut parts = vec![self.kind.human_label().to_string()];
if self.workspace_dirty {
parts.push("dirty worktree".to_string());
}
if self.abandoned {
parts.push("abandoned?".to_string());
}
if let Some(command) = self.pane_command.as_deref() {
parts.push(format!("cmd={command}"));
}
parts.join(" · ")
}
fn json_value(&self) -> serde_json::Value {
json!({
"kind": self.kind.as_str(),
"pane_id": self.pane_id,
"pane_command": self.pane_command,
"pane_path": self.pane_path.as_ref().map(|path| path.display().to_string()),
"workspace_dirty": self.workspace_dirty,
"abandoned": self.abandoned,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct TmuxPaneSnapshot {
pane_id: String,
current_command: String,
current_path: PathBuf,
}
impl GitWorkspaceSummary {
fn is_clean(self) -> bool {
self.changed_files == 0
@@ -2865,6 +2936,120 @@ impl GitWorkspaceSummary {
}
}
fn classify_session_lifecycle_for(workspace: &Path) -> SessionLifecycleSummary {
classify_session_lifecycle_from_panes(workspace, discover_tmux_panes())
}
fn classify_session_lifecycle_from_panes(
workspace: &Path,
panes: Vec<TmuxPaneSnapshot>,
) -> SessionLifecycleSummary {
let workspace_dirty = git_worktree_is_dirty(workspace);
let mut idle_shell = None;
for pane in panes {
if !pane_path_matches_workspace(&pane.current_path, workspace) {
continue;
}
if is_idle_shell_command(&pane.current_command) {
idle_shell.get_or_insert(pane);
} else {
return SessionLifecycleSummary {
kind: SessionLifecycleKind::RunningProcess,
pane_id: Some(pane.pane_id),
pane_command: Some(pane.current_command),
pane_path: Some(pane.current_path),
workspace_dirty,
abandoned: false,
};
}
}
if let Some(pane) = idle_shell {
SessionLifecycleSummary {
kind: SessionLifecycleKind::IdleShell,
pane_id: Some(pane.pane_id),
pane_command: Some(pane.current_command),
pane_path: Some(pane.current_path),
workspace_dirty,
abandoned: workspace_dirty,
}
} else {
SessionLifecycleSummary {
kind: SessionLifecycleKind::SavedOnly,
pane_id: None,
pane_command: None,
pane_path: None,
workspace_dirty,
abandoned: workspace_dirty,
}
}
}
fn discover_tmux_panes() -> Vec<TmuxPaneSnapshot> {
let output = Command::new("tmux")
.args([
"list-panes",
"-a",
"-F",
"#{pane_id}\t#{pane_current_command}\t#{pane_current_path}",
])
.output();
let Ok(output) = output else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
let stdout = String::from_utf8_lossy(&output.stdout);
parse_tmux_pane_snapshots(&stdout)
}
fn parse_tmux_pane_snapshots(output: &str) -> Vec<TmuxPaneSnapshot> {
output
.lines()
.filter_map(|line| {
let mut fields = line.splitn(3, '\t');
let pane_id = fields.next()?.trim();
let current_command = fields.next()?.trim();
let current_path = fields.next()?.trim();
if pane_id.is_empty() || current_path.is_empty() {
return None;
}
Some(TmuxPaneSnapshot {
pane_id: pane_id.to_string(),
current_command: current_command.to_string(),
current_path: PathBuf::from(current_path),
})
})
.collect()
}
fn pane_path_matches_workspace(pane_path: &Path, workspace: &Path) -> bool {
let pane_path = fs::canonicalize(pane_path).unwrap_or_else(|_| pane_path.to_path_buf());
let workspace = fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
pane_path == workspace || pane_path.starts_with(&workspace)
}
fn is_idle_shell_command(command: &str) -> bool {
let command = command.rsplit('/').next().unwrap_or(command);
matches!(
command,
"bash" | "zsh" | "sh" | "fish" | "nu" | "pwsh" | "powershell" | "cmd"
)
}
fn git_worktree_is_dirty(workspace: &Path) -> bool {
let output = Command::new("git")
.arg("-C")
.arg(workspace)
.args(["status", "--porcelain"])
.output();
output
.ok()
.filter(|output| output.status.success())
.is_some_and(|output| !output.stdout.is_empty())
}
#[cfg(test)]
fn format_unknown_slash_command_message(name: &str) -> String {
let suggestions = suggest_slash_commands(name);
@@ -3407,6 +3592,18 @@ fn run_resume_command(
} if act == "list" => {
let sessions = list_managed_sessions().unwrap_or_default();
let session_ids: Vec<String> = sessions.iter().map(|s| s.id.clone()).collect();
let session_details: Vec<serde_json::Value> = sessions
.iter()
.map(|session| {
serde_json::json!({
"id": session.id,
"path": session.path.display().to_string(),
"message_count": session.message_count,
"updated_at_ms": session.updated_at_ms,
"lifecycle": session.lifecycle.json_value(),
})
})
.collect();
let active_id = session.session_id.clone();
let text = render_session_list(&active_id).unwrap_or_else(|e| format!("error: {e}"));
Ok(ResumeCommandOutcome {
@@ -3415,6 +3612,7 @@ fn run_resume_command(
json: Some(serde_json::json!({
"kind": "session_list",
"sessions": session_ids,
"session_details": session_details,
"active": active_id,
})),
})
@@ -3646,6 +3844,7 @@ struct ManagedSessionSummary {
message_count: usize,
parent_session_id: Option<String>,
branch_name: Option<String>,
lifecycle: SessionLifecycleSummary,
}
struct LiveCli {
@@ -5251,7 +5450,9 @@ fn resolve_managed_session_path(session_id: &str) -> Result<PathBuf, Box<dyn std
}
fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::error::Error>> {
Ok(current_session_store()?
let store = current_session_store()?;
let lifecycle = classify_session_lifecycle_for(store.workspace_root());
Ok(store
.list_sessions()
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?
.into_iter()
@@ -5263,12 +5464,15 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
message_count: session.message_count,
parent_session_id: session.parent_session_id,
branch_name: session.branch_name,
lifecycle: lifecycle.clone(),
})
.collect())
}
fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error::Error>> {
let session = current_session_store()?
let store = current_session_store()?;
let lifecycle = classify_session_lifecycle_for(store.workspace_root());
let session = store
.latest_session()
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
Ok(ManagedSessionSummary {
@@ -5279,6 +5483,7 @@ fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error:
message_count: session.message_count,
parent_session_id: session.parent_session_id,
branch_name: session.branch_name,
lifecycle,
})
}
@@ -5343,8 +5548,9 @@ fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::e
(None, None) => String::new(),
};
lines.push(format!(
" {id:<20} {marker:<10} msgs={msgs:<4} modified={modified}{lineage} path={path}",
" {id:<20} {marker:<10} lifecycle={lifecycle} msgs={msgs:<4} modified={modified}{lineage} path={path}",
id = session.id,
lifecycle = session.lifecycle.signal(),
msgs = session.message_count,
modified = format_session_modified_age(session.modified_epoch_millis),
lineage = lineage,
@@ -5527,6 +5733,7 @@ fn status_json_value(
// .claw/sessions/. Extract the stem (drop the .jsonl extension).
path.file_stem().map(|n| n.to_string_lossy().into_owned())
}),
"session_lifecycle": context.session_lifecycle.json_value(),
"loaded_config_files": context.loaded_config_files,
"discovered_config_files": context.discovered_config_files,
"memory_file_count": context.memory_file_count,
@@ -5582,7 +5789,7 @@ fn status_context(
parse_git_status_metadata(project_context.git_status.as_deref());
let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref());
Ok(StatusContext {
cwd,
cwd: cwd.clone(),
session_path: session_path.map(Path::to_path_buf),
loaded_config_files,
discovered_config_files,
@@ -5590,6 +5797,7 @@ fn status_context(
project_root,
git_branch,
git_summary,
session_lifecycle: classify_session_lifecycle_for(&cwd),
sandbox_status,
config_load_error,
})
@@ -5663,6 +5871,7 @@ fn format_status_report(
Unstaged {}
Untracked {}
Session {}
Lifecycle {}
Config files loaded {}/{}
Memory files {}
Suggested flow /status → /diff → /commit",
@@ -5681,6 +5890,7 @@ fn format_status_report(
|| "live-repl".to_string(),
|path| path.display().to_string()
),
context.session_lifecycle.signal(),
context.loaded_config_files,
context.discovered_config_files,
context.memory_file_count,
@@ -8952,6 +9162,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
writeln!(out)?;
let resume_commands = resume_supported_slash_commands()
.into_iter()
.filter(|spec| !STUB_COMMANDS.contains(&spec.name))
.map(|spec| match spec.argument_hint {
Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
None => format!("/{}", spec.name),
@@ -9025,10 +9236,10 @@ fn print_help(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::
mod tests {
use super::{
build_runtime_plugin_state_with_loader, build_runtime_with_plugin_state,
classify_error_kind, collect_session_prompt_history, create_managed_session_handle,
describe_tool_progress, filter_tool_specs, format_bughunter_report,
format_commit_preflight_report, format_commit_skipped_report, format_compact_report,
format_connected_line, format_cost_report, format_history_timestamp,
classify_error_kind, classify_session_lifecycle_from_panes, collect_session_prompt_history,
create_managed_session_handle, describe_tool_progress, filter_tool_specs,
format_bughunter_report, format_commit_preflight_report, format_commit_skipped_report,
format_compact_report, format_connected_line, format_cost_report, format_history_timestamp,
format_internal_prompt_progress_line, format_issue_report, format_model_report,
format_model_switch_report, format_permissions_report, format_permissions_switch_report,
format_pr_report, format_resume_report, format_status_report, format_tool_call_start,
@@ -9039,14 +9250,15 @@ mod tests {
parse_history_count, permission_policy, print_help_to, push_output_block,
render_config_report, render_diff_report, render_diff_report_for, render_help_topic,
render_memory_report, render_prompt_history_report, render_repl_help, render_resume_usage,
render_session_markdown, resolve_model_alias, resolve_model_alias_with_config,
resolve_repl_model, resolve_session_reference, response_to_events,
resume_supported_slash_commands, run_resume_command, short_tool_id,
render_session_list, render_session_markdown, resolve_model_alias,
resolve_model_alias_with_config, resolve_repl_model, resolve_session_reference,
response_to_events, resume_supported_slash_commands, run_resume_command, short_tool_id,
slash_command_completion_candidates_with_sessions, split_error_hint, status_context,
summarize_tool_payload_for_markdown, try_resolve_bare_skill_prompt, validate_no_args,
write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary,
InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic,
PromptHistoryEntry, SlashCommand, StatusUsage, DEFAULT_MODEL, LATEST_SESSION_REFERENCE,
status_json_value, summarize_tool_payload_for_markdown, try_resolve_bare_skill_prompt,
validate_no_args, write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor,
GitWorkspaceSummary, InternalPromptProgressEvent, InternalPromptProgressState, LiveCli,
LocalHelpTopic, PromptHistoryEntry, SessionLifecycleKind, SessionLifecycleSummary,
SlashCommand, StatusUsage, TmuxPaneSnapshot, DEFAULT_MODEL, LATEST_SESSION_REFERENCE,
STUB_COMMANDS,
};
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
@@ -11637,6 +11849,14 @@ mod tests {
untracked_files: 1,
conflicted_files: 0,
},
session_lifecycle: SessionLifecycleSummary {
kind: SessionLifecycleKind::IdleShell,
pane_id: Some("%7".to_string()),
pane_command: Some("zsh".to_string()),
pane_path: Some(PathBuf::from("/tmp/project")),
workspace_dirty: true,
abandoned: true,
},
sandbox_status: runtime::SandboxStatus::default(),
config_load_error: None,
},
@@ -11659,11 +11879,149 @@ mod tests {
assert!(status.contains("Unstaged 1"));
assert!(status.contains("Untracked 1"));
assert!(status.contains("Session session.jsonl"));
assert!(
status.contains("Lifecycle idle shell · dirty worktree · abandoned? · cmd=zsh")
);
assert!(status.contains("Config files loaded 2/3"));
assert!(status.contains("Memory files 4"));
assert!(status.contains("Suggested flow /status → /diff → /commit"));
}
#[test]
fn session_lifecycle_prefers_running_process_over_idle_shell() {
let workspace = PathBuf::from("/tmp/project");
let lifecycle = classify_session_lifecycle_from_panes(
&workspace,
vec![
TmuxPaneSnapshot {
pane_id: "%1".to_string(),
current_command: "zsh".to_string(),
current_path: workspace.clone(),
},
TmuxPaneSnapshot {
pane_id: "%2".to_string(),
current_command: "claw".to_string(),
current_path: workspace.join("rust"),
},
],
);
assert_eq!(lifecycle.kind, SessionLifecycleKind::RunningProcess);
assert_eq!(lifecycle.pane_id.as_deref(), Some("%2"));
assert_eq!(lifecycle.pane_command.as_deref(), Some("claw"));
assert!(!lifecycle.abandoned);
}
#[test]
fn session_lifecycle_marks_dirty_idle_shell_as_abandoned() {
let _guard = env_lock();
let workspace = temp_workspace("dirty-idle-shell");
fs::create_dir_all(&workspace).expect("workspace should create");
git(&["init", "--quiet"], &workspace);
git(&["config", "user.email", "tests@example.com"], &workspace);
git(&["config", "user.name", "Rusty Claude Tests"], &workspace);
fs::write(workspace.join("tracked.txt"), "hello\n").expect("write tracked");
git(&["add", "tracked.txt"], &workspace);
git(&["commit", "-m", "init", "--quiet"], &workspace);
fs::write(workspace.join("tracked.txt"), "hello\nchanged\n").expect("dirty tracked");
let lifecycle = classify_session_lifecycle_from_panes(
&workspace,
vec![TmuxPaneSnapshot {
pane_id: "%3".to_string(),
current_command: "bash".to_string(),
current_path: workspace.clone(),
}],
);
assert_eq!(lifecycle.kind, SessionLifecycleKind::IdleShell);
assert!(lifecycle.workspace_dirty);
assert!(lifecycle.abandoned);
fs::remove_dir_all(workspace).expect("cleanup temp dir");
}
#[test]
fn session_list_surfaces_saved_dirty_abandoned_lifecycle() {
let _guard = cwd_guard();
let workspace = temp_workspace("session-list-lifecycle");
fs::create_dir_all(&workspace).expect("workspace should create");
git(&["init", "--quiet"], &workspace);
git(&["config", "user.email", "tests@example.com"], &workspace);
git(&["config", "user.name", "Rusty Claude Tests"], &workspace);
fs::write(workspace.join(".gitignore"), ".claw/\n").expect("write gitignore");
fs::write(workspace.join("tracked.txt"), "hello\n").expect("write tracked");
git(&["add", ".gitignore", "tracked.txt"], &workspace);
git(&["commit", "-m", "init", "--quiet"], &workspace);
let previous = std::env::current_dir().expect("cwd");
std::env::set_current_dir(&workspace).expect("switch cwd");
let handle = create_managed_session_handle("session-alpha").expect("session handle");
Session::new()
.with_workspace_root(workspace.clone())
.with_persistence_path(handle.path.clone())
.save_to_path(&handle.path)
.expect("session should save");
fs::write(workspace.join("tracked.txt"), "hello\nchanged\n").expect("dirty tracked");
let report = render_session_list("session-alpha").expect("session list should render");
assert!(report.contains("session-alpha"));
assert!(report.contains("lifecycle=saved only · dirty worktree · abandoned?"));
std::env::set_current_dir(previous).expect("restore cwd");
fs::remove_dir_all(workspace).expect("cleanup temp dir");
}
#[test]
fn status_json_surfaces_session_lifecycle_for_clawhip() {
let context = super::StatusContext {
cwd: PathBuf::from("/tmp/project"),
session_path: None,
loaded_config_files: 0,
discovered_config_files: 0,
memory_file_count: 0,
project_root: Some(PathBuf::from("/tmp/project")),
git_branch: Some("feature/session-lifecycle".to_string()),
git_summary: GitWorkspaceSummary::default(),
session_lifecycle: SessionLifecycleSummary {
kind: SessionLifecycleKind::RunningProcess,
pane_id: Some("%9".to_string()),
pane_command: Some("claw".to_string()),
pane_path: Some(PathBuf::from("/tmp/project")),
workspace_dirty: false,
abandoned: false,
},
sandbox_status: runtime::SandboxStatus::default(),
config_load_error: None,
};
let value = status_json_value(
Some("claude-sonnet"),
StatusUsage {
message_count: 0,
turns: 0,
latest: runtime::TokenUsage::default(),
cumulative: runtime::TokenUsage::default(),
estimated_tokens: 0,
},
"workspace-write",
&context,
None,
None,
);
assert_eq!(
value["workspace"]["session_lifecycle"]["kind"],
"running_process"
);
assert_eq!(
value["workspace"]["session_lifecycle"]["pane_command"],
"claw"
);
assert_eq!(value["workspace"]["session_lifecycle"]["abandoned"], false);
}
#[test]
fn commit_reports_surface_workspace_context() {
let summary = GitWorkspaceSummary {
@@ -13000,6 +13358,32 @@ UU conflicted.rs",
);
}
}
#[test]
fn stub_commands_absent_from_resume_safe_help() {
let mut help = Vec::new();
print_help_to(&mut help).expect("help should render");
let help = String::from_utf8(help).expect("help should be utf8");
let resume_line = help
.lines()
.find(|line| line.starts_with("Resume-safe commands:"))
.expect("resume-safe command line should exist");
let resume_roots = resume_line
.trim_start_matches("Resume-safe commands:")
.split(',')
.filter_map(|entry| entry.trim().strip_prefix('/'))
.filter_map(|entry| entry.split_whitespace().next())
.collect::<Vec<_>>();
for stub in STUB_COMMANDS {
assert!(
!resume_roots.contains(stub),
"stub command /{stub} should not appear in resume-safe command list"
);
}
assert!(resume_roots.contains(&"status"));
}
}
fn write_mcp_server_fixture(script_path: &Path) {