mirror of
https://github.com/instructkr/claude-code.git
synced 2026-06-06 12:16:44 +00:00
Compare commits
10 Commits
cb56dc12ab
...
docs/roadm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b5bafc847 | ||
|
|
e7074f47ee | ||
|
|
9468383b67 | ||
|
|
1da2781816 | ||
|
|
9037430d52 | ||
|
|
8e22f757d8 | ||
|
|
7676b376ae | ||
|
|
1011a83823 | ||
|
|
1376d92064 | ||
|
|
be53e04671 |
13
ROADMAP.md
13
ROADMAP.md
@@ -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.
|
||||
|
||||
11
progress.txt
11
progress.txt
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user