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
//! Session launch and connect methods for [`DaemonClient`].
//!
//! Why: `launch_session` and `connect_session` are the two largest methods on
//! [`DaemonClient`] (~170 SLOC combined) and are cohesive enough to live
//! together — both POST a session registration to the daemon then drive tmux to
//! create or attach the actual shell. Isolating them here keeps `mod.rs` under
//! the 500-SLOC production cap while preserving the logical grouping.
//! What: a second `impl DaemonClient` block containing only the two session-
//! lifecycle entry-point methods.
//! Test: `launch_session_errors_when_daemon_unreachable`,
//! `connect_session_errors_when_daemon_unreachable` in `tests.rs`.
use serde::Deserialize;
use super::DaemonClient;
impl DaemonClient {
/// Launch a fresh Claude Code session in `workdir`.
///
/// Why: the TUI's `/connect <dir>` command is the single entry point for
/// "connect to or launch a session for a project" — when no session exists
/// for a directory it must start one, mirroring `tm session start`. A
/// trusty-mpm session is always the `claude` (Claude Code) CLI, never
/// `claude-mpm`; the trusty-mpm behaviour comes from the custom instructions
/// (deployed agents + project `CLAUDE.md`) prepared before launch.
/// What: runs [`crate::core::session_launch::prepare_session`] (deploy
/// agents + merge `CLAUDE.md`), POSTs `{project, project_path}` to
/// `/sessions`, then creates a detached tmux session via `tmux new-session`
/// and starts `claude` in it via `tmux send-keys`. Returns the
/// daemon-assigned tmux session name. The daemon only registers session
/// state; the prep and launch (tmux + process) are owned by the client,
/// exactly as the CLI does it.
/// Test: `launch_session_errors_when_daemon_unreachable`.
pub async fn launch_session(&self, workdir: &str) -> anyhow::Result<String> {
// Prepare the custom instructions Claude Code reads at startup: deploy
// composed agents to `~/.claude/agents/` and merge the project
// `CLAUDE.md`. Most prep failures are logged but not fatal (#2149) —
// the session can still launch with whatever instructions already exist
// on disk. The exception is #4752's compiled-prompt write.
let fw = crate::core::paths::FrameworkPaths::default();
match crate::core::session_launch::prepare_session(&fw, std::path::Path::new(workdir)) {
Ok(report) => {
// Issue #2149: a roster-deploy failure no longer aborts
// preparation — surface it loudly rather than let it hide.
for err in &report.roster_errors {
tracing::error!(%err, "roster provisioning gap (non-fatal)");
}
}
// #4752: fatal — refuse the launch rather than start a session
// whose compiled instructions could not be written.
Err(err) if err.is_fatal() => {
anyhow::bail!("{err}");
}
Err(err) => {
tracing::warn!(%err, "session pre-launch preparation failed");
}
}
#[derive(Deserialize)]
struct Body {
#[serde(default)]
name: String,
}
let url = format!("{}/sessions", self.base);
let body: Body = self
.http
.post(&url)
.json(&serde_json::json!({
"project": workdir,
"project_path": workdir,
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
// Build the combined `--append-system-prompt` text (claude-mpm PM
// instructions + trusty tool-priority block), resolved *for this project
// directory* so override files under `<workdir>/.trusty-mpm/` take effect
// (issue #381). Write it to a temp file and pass it via
// `--append-system-prompt-file` so every launched `claude` is a properly
// configured PM instance while preserving Claude Code's built-in tool use
// instructions. The temp file persists because `claude` reads it at
// startup; it lives in `/tmp` and is superseded by the next launch — no
// explicit cleanup is performed.
let prompt =
crate::core::session_launch::build_system_prompt_for(std::path::Path::new(workdir));
let claude_cmd = {
let path = std::env::temp_dir().join(format!(
"trusty-mpm-system-prompt-{}.txt",
uuid::Uuid::new_v4()
));
// #4467: the line is built in `core::model_inject` so it carries the
// shared inherited-marker scrub and the `transcript_saving` doctor
// check can read it. Hand-building it here is what left this launch
// path silently saving no transcript.
match std::fs::write(&path, &prompt) {
Ok(()) => crate::core::model_inject::build_client_session_command(Some(&path)),
Err(err) => {
tracing::warn!(%err, "failed to write system prompt file; launching bare claude");
crate::core::model_inject::build_client_session_command(None)
}
}
};
// #2398: routes through `core::tmux::create_managed_session`, the
// crate's single session-creation choke point, so the configured
// scrollback/mouse ergonomics are applied before the pane exists (a
// bare `tmux new-session` here would silently bypass them — the
// exact QA-caught regression this consolidation closes).
let new_session =
crate::core::tmux::create_managed_session(None, &body.name, Some(workdir));
match new_session {
Ok(outcome) if outcome.output.status.success() => {
// #3386 review: operator-visible (not just grep-able) notice
// — this client is shared by the TUI/bot surfaces, so the
// `tracing::error!` `warn_if_options_unverified` emits is the
// channel every one of them can observe.
crate::core::tmux::warn_if_options_unverified(&outcome, &body.name);
let send = crate::core::tmux::send_line(
None,
&crate::core::tmux::TmuxTarget::session(&body.name),
&claude_cmd,
)
.map(|output| output.status);
if !matches!(send, Ok(s) if s.success()) {
return Err(anyhow::anyhow!(
"tmux session {} created but failed to start claude",
body.name
));
}
}
Ok(_) | Err(_) => {
return Err(anyhow::anyhow!(
"failed to create tmux session {} in {}",
body.name,
workdir
));
}
}
Ok(body.name)
}
/// Connect to — or start — a Claude Code session in `workdir` *without*
/// running the framework-deployment sequence.
///
/// Why: `tm connect` is the lightweight sibling of `launch_session`. Where
/// `launch_session` first runs
/// [`crate::core::session_launch::prepare_session`] to deploy
/// instructions, agents, and skills into the project, `connect` deliberately
/// skips all of that — it assumes the framework is already deployed (or that
/// the operator does not want it touched) and only wants the daemon to know
/// about the session and the tmux host to be running.
/// What: POSTs `{project, project_path}` to `/api/v1/sessions/connect`, then
/// runs `tmux new-session -A` (idempotent — creates the session when absent,
/// no-ops when it already exists). When the session is freshly created it
/// starts `claude` in it via `tmux send-keys`; an already-running session is
/// left untouched. The system-prompt file is still built and passed so a
/// freshly-started `claude` is a configured PM — that is prompt composition,
/// not artifact deployment. Returns the daemon-assigned tmux session name.
/// Test: `connect_session_errors_when_daemon_unreachable`.
pub async fn connect_session(&self, workdir: &str) -> anyhow::Result<String> {
#[derive(Deserialize)]
struct Body {
#[serde(default)]
name: String,
}
let url = format!("{}/api/v1/sessions/connect", self.base);
let body: Body = self
.http
.post(&url)
.json(&serde_json::json!({
"project": workdir,
"project_path": workdir,
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
// Build the `--append-system-prompt` text so a freshly-started `claude`
// is a configured PM, resolved *for this project directory* so override
// files under `<workdir>/.trusty-mpm/` take effect (issue #381). This is
// prompt composition from bundled assets + project overrides, not
// deployment of agents/skills/hooks into the project — `connect` only
// skips the latter (`prepare_session`).
let prompt =
crate::core::session_launch::build_system_prompt_for(std::path::Path::new(workdir));
let claude_cmd = {
let path = std::env::temp_dir().join(format!(
"trusty-mpm-system-prompt-{}.txt",
uuid::Uuid::new_v4()
));
// #4467: the line is built in `core::model_inject` so it carries the
// shared inherited-marker scrub and the `transcript_saving` doctor
// check can read it. Hand-building it here is what left this launch
// path silently saving no transcript.
match std::fs::write(&path, &prompt) {
Ok(()) => crate::core::model_inject::build_client_session_command(Some(&path)),
Err(err) => {
tracing::warn!(%err, "failed to write system prompt file; launching bare claude");
crate::core::model_inject::build_client_session_command(None)
}
}
};
// `tmux new-session -A` is idempotent: it attaches to the session when
// it already exists and creates it (detached, `-d`) otherwise. The
// `has-session` probe distinguishes the two so `claude` is started only
// for a freshly-created session — an already-running one is left alone.
// #2398: routes through the crate's single tmux entry point.
let already_running =
crate::core::tmux::run_tmux(&crate::core::tmux::TmuxCommand::HasSession {
name: body.name.clone(),
})
.map(|output| output.status.success())
.unwrap_or(false);
// #2398: routes through `core::tmux::create_managed_session`, the
// crate's single session-creation choke point, so the configured
// scrollback/mouse ergonomics are applied before the pane exists.
let new_session =
crate::core::tmux::create_managed_session(None, &body.name, Some(workdir));
match new_session {
Ok(outcome) if outcome.output.status.success() => {
// #3386 review: see `launch_session`'s identical notice above.
crate::core::tmux::warn_if_options_unverified(&outcome, &body.name);
if !already_running {
let send = crate::core::tmux::send_line(
None,
&crate::core::tmux::TmuxTarget::session(&body.name),
&claude_cmd,
)
.map(|output| output.status);
if !matches!(send, Ok(s) if s.success()) {
return Err(anyhow::anyhow!(
"tmux session {} created but failed to start claude",
body.name
));
}
}
}
Ok(_) | Err(_) => {
return Err(anyhow::anyhow!(
"failed to create tmux session {} in {}",
body.name,
workdir
));
}
}
Ok(body.name)
}
}