1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
//! [`SessionSummary`] — the per-session wire shape shared by the list/get
//! managed-session endpoints.
//!
//! Why: split out of `mod.rs` (which sits at its 500-SLOC production cap) so
//! adding the `stale_assets` field (issue #2444) has somewhere to land without
//! needing a broader `mod.rs` refactor; mirrors the `doctor_output_style.rs` /
//! `doctor_fs_checks.rs` split precedent used elsewhere for the same reason.
//! What: the flat, string-typed summary every managed-session list/get/mutate
//! handler returns.
//! Test: list/get handler tests in `tests/session_manager_mvp.rs`; the
//! `stale_assets` field specifically is covered by
//! `checked_summaries_flags_stale_assets_only_for_relevant_states` in
//! `super::tests`.
use serde::Serialize;
/// Per-session summary for the list endpoint.
///
/// Why: the list endpoint returns less detail than the single-session endpoint;
/// keeping a summary type avoids serializing the full record in list responses.
/// What: id, name, state, workspace_path, repo_url, branch, timestamps,
/// task, cwd, pending_decision, proposed_default, source_id.
/// Test: list handler test.
#[derive(Debug, Serialize)]
pub struct SessionSummary {
/// Managed session id.
pub id: String,
/// tmux session name.
pub name: String,
/// Lifecycle state (#3302: reconciled against LIVE tmux for DISPLAY by
/// [`super::summary::reconcile_live_state`] when this summary came from
/// the list/get endpoints — an `Active`/`Stopped` record whose tmux
/// session is absent/present is shown as `stopped`/`active` regardless of
/// what is actually persisted). Use [`Self::persisted_state`], not this
/// field, for any RESUME/RESTART decision — see that field's doc (#3531).
pub state: String,
/// The RAW, un-reconciled lifecycle state exactly as persisted in the
/// store — set once at [`super::summary::record_to_summary`] and never
/// touched by the later display-only tmux reconciliation that may
/// overwrite [`Self::state`] (#3531).
///
/// Why: the daemon's own `/resume` endpoint
/// ([`crate::session_manager::SessionManager::resume`]) validates a
/// restart against the PERSISTED state only — it has no idea a caller's
/// copy of `state` was display-reconciled. Before this field existed, a
/// zombie session (record still `Active`/`Provisioning` but its tmux
/// pane gone) had its displayed `state` collapsed to `stopped` by
/// [`super::summary::reconcile_live_state`] — indistinguishable, from the
/// CLI's point of view, from a session that is GENUINELY `Stopped`. The
/// CLI's own zombie auto-reconcile (#2001,
/// `bin/tm/commands/guided_resume::plan_resume`) then misclassified the
/// zombie as a plain `Restart` instead of `ReconcileThenRestart`, and the
/// daemon's `/resume` rejected it with a 409 ("cannot resume a session in
/// state 'active'") — the exact dead-end #3531 reported. Restart/resume
/// decisions must key off THIS field so they always agree with what the
/// daemon's own state-gate will actually check.
/// What: identical to `state` for every summary NOT produced by
/// `list_managed_sessions`/`get_managed_session` (nothing reconciles it);
/// for those two, it is the value `state` held BEFORE reconciliation.
/// Test: `reconcile_live_state_leaves_persisted_state_untouched` in
/// `super::tests`.
pub persisted_state: String,
/// Provisioned workspace path.
pub workspace_path: Option<String>,
/// Repository URL.
pub repo_url: Option<String>,
/// Git branch or ref.
pub branch: Option<String>,
/// Creation timestamp (RFC 3339).
pub created_at: String,
/// Last activity timestamp (RFC 3339), if any.
pub last_activity_at: Option<String>,
/// A pending decision question, if surfaced.
pub pending_decision: Option<String>,
/// Proposed default answer to the pending decision.
pub proposed_default: Option<String>,
/// Source project identity (`owner/repo`) for in-project sessions (#1707).
///
/// Why: the in-project spawn path records the GitHub identity so callers can
/// filter sessions by project and reconnect to existing ones.
/// `None` for sessions not created via the in-project path.
pub source_id: Option<String>,
/// Task description for the session (additive; absent for legacy records).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task: Option<String>,
/// Working directory for the session (additive; absent for legacy records).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
/// Captured Claude Code conversation id, if any (additive; #2023 C).
///
/// Why: the bare-`tm` in-pane relaunch path (#2023 component C) needs the
/// SAME `claude_session_id` the tmux-pane resume path uses for its
/// `--resume <id>` existence-check-and-fallback logic (#2013) — exposing it
/// on the wire lets the CLI build the identical command without a second,
/// divergent lookup. `None` for sessions where no `SessionStart` capture has
/// landed yet, or for legacy records predating the field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_session_id: Option<String>,
/// The Deliverable this session is working on, if bound (DOC-35 §10.6,
/// #2379; additive — absent for legacy records and sessions with no link).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deliverable_id: Option<String>,
/// The tmux `pane_id` of this session's original pane, if captured
/// (additive; #2453 review finding 1, round 2 — absent for legacy
/// records, or when the driver could not resolve one).
///
/// Why: the bare-`tm` in-pane relaunch's nested-session guard needs this
/// to confirm the operator's CURRENT pane is genuinely the one bound to
/// this record before driving a destructive `exec` — see
/// `SessionRecord::pane_id`'s doc for the full rationale (a session-name
/// or process-env-var match alone is provably insufficient).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pane_id: Option<String>,
/// Delivery status of the turnkey `--task` pane injection, if injection
/// was ever attempted for this session (additive; #2364).
///
/// Why: `SessionManager::inject_task_when_ready` was fire-and-forget
/// before this field existed — callers had no way to poll whether an
/// injected task was actually delivered. Exposing it lets `tm session
/// info`/`tm sessions ls` surface `pending`/`success`/`failed_timeout`/
/// `failed_session_died` instead of requiring a blind wait on `tm session
/// activity`. `None` when injection was never attempted for this session
/// (opted out, empty task, non-Claude-Code runtime, or a spawn that never
/// reached `Active`) — see `session_manager::InjectionStatus::NotApplicable`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub injection_status: Option<String>,
/// True when this session is a dead pick: it is `stopped`/`errored` AND no
/// workdir candidate (`last_cwd`, `workspace_path`, `cwd`) exists on disk
/// any more, so a resume is guaranteed to fail (#2595).
///
/// Why: #2577/#2594 fixed the ERROR an operator sees after picking a
/// GC-pruned session to restart (bare 500 → actionable 422); the deeper UX
/// defect was that such a session was OFFERED as a restart option at all.
/// Computing this once here — server-side, where the full record (`last_cwd`
/// included) and filesystem access both live — lets every listing surface
/// (the bare-`tm` guided default, the `tm ls` picker, and `tm sessions ls`,
/// all of which read this same list endpoint) mark/exclude the session
/// BEFORE the operator selects it, instead of only failing loudly after the
/// fact. Always `false` for live/provisioning/decommissioned sessions —
/// see `session_manager::resume_workdir::is_unresumable`.
/// What: computed by [`super::list_managed_sessions`]/[`super::get_managed_session`]
/// via `session_manager::resume_workdir::is_unresumable`; every other
/// handler that builds a `SessionSummary` via `record_to_summary` leaves it
/// at its `false` default (freshly spawned/reactivated/decommissioned
/// sessions are never mid-flight through this predicate).
/// Test: `list_marks_dead_stopped_session_unresumable`,
/// `list_leaves_live_and_healthy_stopped_sessions_unmarked` in
/// `super::tests`.
#[serde(default)]
pub unresumable: bool,
/// True when this session's deployed `.claude/{agents,skills}` have
/// drifted from the current bundled/catalog source (issue #2444).
///
/// Why: `#2002`'s asset deployment is one-shot at launch — a long-lived
/// session never re-syncs when the catalog/bundled agent or skill source
/// changes underneath it, so it silently keeps running stale content
/// (skills, agent rosters) with no signal anywhere that a refresh is due.
/// Surfacing it here, alongside `unresumable`, lets every listing surface
/// that already reads this endpoint (`tm sessions ls`, the picker) flag a
/// session that needs `tm sessions sync-assets` run against it. Always
/// `false` for `provisioning`/`decommissioned` sessions — see
/// `summary::staleness_meaningful_for` for exactly which states are worth
/// probing — and, on the FLEET LIST path only, also `false` for `stopped`
/// sessions, which that path no longer probes for latency (#4322). Such a
/// row sets [`Self::stale_assets_unchecked`], so a `false` here means "not
/// determined", NOT "fresh".
/// What: computed by `checked_summaries`/`record_to_summary_checked` via
/// [`crate::core::session_assets::session_assets_stale`]; every other
/// handler that builds a `SessionSummary` via `record_to_summary` leaves it
/// at its `false` default.
/// Test: `checked_summaries_flags_stale_assets_only_for_relevant_states` in
/// `super::tests`.
#[serde(default)]
pub stale_assets: bool,
/// True when this response did NOT determine `stale_assets` for a session
/// whose assets it WOULD be meaningful to check (issue #4322).
///
/// Why: the fleet LIST path stopped probing `Stopped` sessions — their
/// per-session filesystem comparison (~95 reads each) dominated cold `tm
/// ls` latency while telling the operator nothing they could not learn at
/// resume time. But a bare `stale_assets: false` on those rows would be a
/// LIE by omission: it is indistinguishable from a genuine "checked, and
/// fresh" verdict, and an operator (or a future consumer) reading absence
/// as freshness would be wrong. This flag makes "we did not look" an
/// explicit, first-class third state on the wire so the CLI can render
/// `[assets ?]` rather than silence. Polarity is chosen so the DEFAULT
/// (`false`) means "this verdict is authoritative" — an OLDER daemon that
/// omits the field probed everything, so `#[serde(default)]` deserializes
/// its responses to exactly the right answer.
/// What: `true` only on the LIST path, only for `Stopped` records (states
/// where `summary::staleness_meaningful_for` holds but
/// `summary::probe_staleness_in_list` does not). Always `false` for
/// `Provisioning`/`Decommissioned`/tombstone rows — nothing to check
/// there — and always `false` on the single-session GET, which still
/// probes every meaningful state.
/// Test: `checked_summaries_does_not_probe_stopped_sessions` in
/// `super::tests`.
#[serde(default)]
pub stale_assets_unchecked: bool,
/// True when a tmux client is currently ATTACHED to this session's tmux
/// session (a live probe reconciled against real tmux, not the persisted
/// `state`).
///
/// Why: after a daemon restart (before the reconcile pass runs) a record's
/// persisted `state` can read `stopped` even though its tmux session is
/// alive and the operator is attached — the `tm ls` picker then wrongly
/// showed EVERY session as `(stopped)` and offered `restart`. The list
/// handler now reconciles the displayed `state` against live tmux and, when
/// a client is attached, sets this flag so surfaces render `attached` and
/// offer connect rather than a destructive restart.
/// What: computed by [`super::list_managed_sessions`] from the live tmux
/// attached-session set; `false` on every non-list summary (the default).
/// Test: `reconcile_live_state_flips_stopped_to_active_when_alive` (unit);
/// end-to-end coverage in the `session_lifecycle` integration suite.
#[serde(default)]
pub attached: bool,
/// Stable, daemon-lifetime `tm ls` slot number (issue #3034).
///
/// Why: renumbering sessions on every fetch let an operator (or PM agent)
/// holding a number from an earlier listing act on the WRONG session once
/// a delete shifted every later row down. `0` (the default) means "no
/// slot assigned" — only [`super::list_managed_sessions`] computes a real
/// slot via `SessionManager::numbered_snapshot`; every other handler that
/// builds a `SessionSummary` via `record_to_summary` leaves it at `0`,
/// since slot numbers are meaningful only on the numbered listing surface.
/// What: 1-based; assigned once per session id for the life of the daemon
/// process and never reused, even after the session is deleted.
/// Test: `numbered_snapshot_keeps_slot_stable_across_delete` (session
/// manager); `list_assigns_stable_slot_numbers_and_tombstones_deleted_one`
/// (handler integration test, `tests/session_manager_slots.rs`).
#[serde(default)]
pub slot: u32,
/// True when this row is a tombstone placeholder for a slot whose session
/// no longer exists in the store (issue #3034).
///
/// Why: Bob's requirement — a deleted session's slot renders as
/// `-- deleted --` rather than disappearing (which would let the next
/// session silently inherit its number). Every other field is blank when
/// this is `true`.
/// What: `false` for every session `record_to_summary` builds directly;
/// only [`super::list_managed_sessions`]' tombstone rows set it `true`.
/// Test: `list_assigns_stable_slot_numbers_and_tombstones_deleted_one`
/// (handler integration test, `tests/session_manager_slots.rs`).
#[serde(default)]
pub deleted: bool,
/// Why the supervisor has stopped auto-resuming this session, if it has
/// (#6568).
///
/// Why: a parked session looks exactly like an idle stopped one on every
/// listing surface — the difference is only that nothing will ever revive
/// it. Before this field the only trace was the absence of resume lines in
/// a multi-gigabyte log. Carrying the reason on the wire puts it in
/// `tm session ls`'s and `tm session status`'s JSON, and in the MCP
/// `session_list` / `session_status` tools that pass this shape through.
/// What: `Some(<reason>)` exactly when the record's `stop_cause` is
/// `ResumeFlapping` — the string comes from
/// [`crate::session_manager::SessionRecord::auto_resume_park_reason`], the
/// single place the wording lives. `None` for every other record, and
/// omitted from the JSON entirely so an older consumer sees no change.
/// Test: `summary_carries_the_resume_park_reason` in
/// `session_manager::resume_breaker_tests`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_resume_parked: Option<String>,
/// The record's fleet role (#8942), so a listing can pin and tag the
/// Architect; mirrors `SessionRecord::kind`.
pub kind: crate::session_manager::SessionKind,
}