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
//! Live-background-session liveness probe and the `attach` relaunch shape (#6863).
//!
//! Why: `spawn_resume` chose `--resume <id>` from a filesystem check alone
//! (`session_id_exists` — is `<projects_dir>/<encoded-cwd>/<id>.jsonl` a regular
//! file?). That file exists for a FINISHED conversation and for one Claude Code
//! is still running as a background job, and the two need opposite commands. Sent
//! `--resume` for a live background session, `claude` prints
//!
//! ```text
//! Session <uuid> is running as a background session (<short-id>).
//! Run `claude attach <short-id>` … or `claude stop <short-id>` first
//! ```
//!
//! and exits 0. The pane is left at a bare shell, and the reap loop marks the
//! record Stopped about a minute later. `claude attach <short-id>` opens that
//! same background session in the pane with its conversation intact, which is
//! what the operator asked for.
//!
//! What: [`query_registry`] runs `claude agents --json` — Claude Code's own
//! machine-readable list of live sessions — under a hard time cap;
//! [`attach_id_in_registry`] answers "is this `sessionId` a live background
//! entry, and what is its short id?"; [`relaunch_spec`] makes the choice between
//! `attach`, `--resume` and a fresh launch, and is the single seam
//! `spawn_resume` calls.
//!
//! **Why a registry read and not the refusal text.** `claude_code_exit_hint`
//! (#6766) rejected parsing that message as an undocumented UI surface a
//! reworded release would silently take with it. The same objection applies
//! here, so the decision is made BEFORE the launch, against a `--json` surface
//! that exists to be scripted against.
//!
//! **Every failure falls open to `--resume`.** A missing `claude`, a non-zero
//! exit, a timeout, or unparsable JSON all resolve to "not live", which is
//! exactly the pre-#6863 behavior. The probe can only ever turn a refused
//! relaunch into a working one; it can never turn a working one into a failure.
//!
//! `claude stop <short-id>` is never issued — it is destructive to in-flight
//! work.
//!
//! Test: `relaunch_attaches_to_a_live_background_session`,
//! `relaunch_resumes_a_session_absent_from_the_registry`,
//! `relaunch_resumes_when_the_registry_call_fails`,
//! `registry_sample_parses_every_entry_shape`,
//! `query_registry_kills_and_reaps_a_wedged_probe` — in the sibling `_tests.rs`.
use io;
use Path;
use ;
use Duration;
use Deserialize;
use debug;
use LaunchSpec;
use ;
/// Ceiling on the `claude agents --json` wait. Resume is interactive, so the
/// probe must never be the reason a pane sits blank; three seconds is far above
/// the ~200 ms the command actually takes and still imperceptible when the
/// binary hangs.
const REGISTRY_TIMEOUT: Duration = from_secs;
/// The `kind` a background session carries. An `interactive` entry is a live
/// TTY Claude Code, which `attach` cannot take over and which carries no `id`.
const BACKGROUND_KIND: &str = "background";
/// `state` values that mean the background session is NO LONGER RUNNING.
/// Anything else (`working`, `blocked`, or a state a future release adds) is
/// live, so an unrecognised state attaches rather than colliding with a refusal.
///
/// `stopped` is the state `claude stop <short-id>` leaves behind — the command
/// this module's header quotes from Claude Code's own refusal text. There is no
/// process left to attach to, and the transcript is on disk, so a stopped entry
/// belongs on the `--resume` path with the finished ones (#6863).
const TERMINAL_STATES: = ;
/// One entry of the `claude agents --json` array.
///
/// Why: only four of the emitted fields decide anything, and every one of them
/// is `Option` because the shape varies by entry — an `interactive` session
/// carries neither `id` nor `state`, and `pid`/`status` appear only while a
/// process is attached to the entry. Deserializing into required fields would
/// make one such entry poison the whole array.
/// What: `id` is the short id `claude attach` takes; `sessionId` is the full
/// UUID a tm record stores as its `claude_session_id`.
/// Test: `registry_sample_parses_every_entry_shape`.
/// Read Claude Code's live-session registry, or say why it could not be read.
///
/// Why: this is the only I/O in the module, kept behind its own function so
/// every decision above it is a pure function of a `&str` and can be unit
/// tested without a real `claude` on the machine (#4255 — a test must never
/// touch the operator's real session state).
/// What: spawns `<claude_bin> agents --json` through
/// [`crate::core::spawn_disclaim::disclaimed_stdout_command_with_timeout`] —
/// stdout piped, stderr to `/dev/null`, TCC-disclaimed on macOS — wrapped in
/// [`trusty_common::spawn_retry::retry_on_etxtbsy`], the workspace's one
/// exec-retry policy (only the exec step can raise `ETXTBSY`, so no retry ever
/// re-runs a probe that started). That helper drains stdout off-thread, so a
/// child that outgrew the pipe buffer cannot deadlock the caller, and it
/// SIGKILLs and reaps a child that outlives [`REGISTRY_TIMEOUT`] — a probe
/// process never survives the deadline (#6863; the same guarantee #5969 bought
/// the `claude --version` probe).
///
/// `CLAUDE_CONFIG_DIR` is exported to match the spawn the pane will make.
/// Claude Code 2.1.261 keeps this registry machine-global — verified: the same
/// eleven entries come back with and without the managed config dir set — so
/// today it changes nothing; it is set so a release that DOES scope the
/// registry by config home is read against the right one.
///
/// # Errors
///
/// A spawn failure (no `claude` on PATH), a non-zero exit, or the timeout, each
/// rendered as a one-line reason for the caller's debug log. Every one of them
/// means the same thing to [`relaunch_command`]: fall back to `--resume`.
///
/// Test: `query_registry_kills_and_reaps_a_wedged_probe` proves the deadline
/// leaves no child behind. The live shape it returns is pinned by
/// `registry_sample_parses_every_entry_shape`, which parses a capture of this
/// command's real output; the decision above it is the seam tests inject AROUND.
pub
/// Find the short id to `attach` for a stored `claude_session_id`, if that
/// session is a live background one.
///
/// Why: `claude attach` takes the SHORT id, while a tm record stores the full
/// UUID, so the registry is both the liveness answer and the id translation.
/// What: parses the array and returns the `id` of the first entry whose
/// `sessionId` matches, whose `kind` is [`BACKGROUND_KIND`], and whose `state`
/// is not one of [`TERMINAL_STATES`]. Unparsable JSON, no match, an
/// `interactive` entry, or a finished one all return `None`.
/// Test: `attach_id_found_for_a_live_background_entry`,
/// `attach_id_absent_for_an_unlisted_session`,
/// `attach_id_absent_for_a_finished_background_entry`,
/// `attach_id_absent_for_a_stopped_background_entry`,
/// `attach_id_absent_for_an_interactive_entry`,
/// `attach_id_absent_for_unparsable_json`.
pub
// #8233: `attach_command` built a shell string out of the same five prefixes
// `resume_command` used. Both are now spec builders in `managed_launch`, which
// shares the whole environment between them by construction — see
// `managed_launch::attach_spec`. The choice below is what stayed here.
/// Choose and build the launch spec for a resume: `attach`, `--resume`, or a
/// fresh launch.
///
/// Why: the choice and both builders belong at one seam so `spawn_resume` holds
/// no policy of its own, and so a test can pin the whole decision by handing in
/// a registry string instead of running a real `claude` (the injected-I/O
/// pattern `session_id_exists`/`session_id_exists_in` already uses in this
/// module).
/// What: a `claude_session_id` that [`attach_id_in_registry`] reports live
/// yields [`managed_launch::attach_spec`]; otherwise the pre-#6863 path runs
/// unchanged — [`managed_launch::resume_spec`] with `effective_id` (the caller's
/// `session_id_exists`-filtered id), which is `--resume <uuid>` when that id
/// survived and a fresh launch when it did not. `probe` returns `Err` whenever
/// [`query_registry`] could not answer; the reason is logged at debug and the
/// fallback is taken.
///
/// `probe` is a closure, not an already-read string, so the registry read
/// happens only on the one path that can use it. There is nothing to look up
/// without a `claude_session_id`, and running it anyway shelled out to the
/// operator's real `claude` from every fresh-launch test (#4255, and ~20 s per
/// test — the reason this is lazy).
/// Test: `relaunch_attaches_to_a_live_background_session`,
/// `relaunch_resumes_a_session_absent_from_the_registry`,
/// `relaunch_resumes_when_the_registry_call_fails`,
/// `relaunch_starts_fresh_without_a_usable_id`,
/// `relaunch_never_probes_the_registry_without_a_session_id`,
/// `relaunch_resumes_a_stopped_background_session`.
pub