@team-agent/installer 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/Cargo.lock +118 -9
  2. package/Cargo.toml +2 -2
  3. package/crates/team-agent/Cargo.toml +1 -0
  4. package/crates/team-agent/src/app_server_test_support.rs +258 -0
  5. package/crates/team-agent/src/cli/adapters.rs +32 -3
  6. package/crates/team-agent/src/cli/attach_app_server_leader.rs +16 -0
  7. package/crates/team-agent/src/cli/diagnose.rs +14 -5
  8. package/crates/team-agent/src/cli/emit.rs +111 -3
  9. package/crates/team-agent/src/cli/mod.rs +72 -25
  10. package/crates/team-agent/src/cli/named_address.rs +975 -0
  11. package/crates/team-agent/src/cli/send.rs +200 -1
  12. package/crates/team-agent/src/cli/status_port.rs +44 -6
  13. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  14. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  15. package/crates/team-agent/src/cli/tests/named_address.rs +586 -0
  16. package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
  17. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  18. package/crates/team-agent/src/cli/types.rs +21 -0
  19. package/crates/team-agent/src/codex_app_server.rs +488 -0
  20. package/crates/team-agent/src/conpty/backend.rs +710 -0
  21. package/crates/team-agent/src/conpty/mod.rs +32 -0
  22. package/crates/team-agent/src/coordinator/backoff.rs +45 -6
  23. package/crates/team-agent/src/diagnose/orphans.rs +11 -3
  24. package/crates/team-agent/src/leader/lease.rs +144 -7
  25. package/crates/team-agent/src/leader/owner_bind.rs +5 -2
  26. package/crates/team-agent/src/leader/start.rs +18 -5
  27. package/crates/team-agent/src/leader/tests/lease_api.rs +172 -0
  28. package/crates/team-agent/src/lib.rs +23 -0
  29. package/crates/team-agent/src/lifecycle/launch.rs +127 -3
  30. package/crates/team-agent/src/lifecycle/restart/common.rs +70 -0
  31. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +7 -0
  32. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +90 -0
  33. package/crates/team-agent/src/messaging/delivery.rs +329 -9
  34. package/crates/team-agent/src/messaging/leader_receiver.rs +17 -138
  35. package/crates/team-agent/src/messaging/mod.rs +2 -2
  36. package/crates/team-agent/src/messaging/results.rs +78 -21
  37. package/crates/team-agent/src/messaging/tests/runtime.rs +232 -0
  38. package/crates/team-agent/src/tmux_backend.rs +31 -0
  39. package/crates/team-agent/src/transport_factory.rs +632 -0
  40. package/package.json +4 -4
@@ -0,0 +1,710 @@
1
+ //! `ConPtyBackend` — implements the existing `Transport` trait using the
2
+ //! named-pipe protocol in `super::protocol`.
3
+ //!
4
+ //! ## Design boundary (design.md §Transport Boundary:97-124)
5
+ //!
6
+ //! Every trait method has one of three flavours:
7
+ //!
8
+ //! - **Required**: forwards to the shim via `PipeClient::request`.
9
+ //! - **Exempt typed no-op**: e.g. `configure_adaptive_pane_title` — the
10
+ //! shim has no pane title so the method returns `Ok(())` locally.
11
+ //! - **Typed unsupported**: `attach_session` returns
12
+ //! `AttachOutcome::Unsupported { reason }` because ConPTY has no attach
13
+ //! concept (design.md:123-124; there is no `tmux attach`).
14
+ //!
15
+ //! ## Phase 1a scope
16
+ //!
17
+ //! This file lands the Transport impl **shape** + honest degradation
18
+ //! when no pipe client is connected. The pipe transport itself
19
+ //! (Windows named-pipe socket + real shim binary) lives in Phase 1b.
20
+ //!
21
+ //! When a `PipeClient` is not available (Mac dev host, tests, or a live
22
+ //! Windows host whose shim died), every trait method returns
23
+ //! `TransportError::MuxUnavailable { backend: BackendKind::ConPty, detail }`
24
+ //! — the same honest-fail path the coordinator uses today when a tmux
25
+ //! server is missing. This satisfies MUST-NOT-13 (do not silently
26
+ //! success) + CR C-3 (stale shim → honest degradation).
27
+ //!
28
+ //! ## CR anchors
29
+ //!
30
+ //! - **C-1 pipe_token**: only lives in `PipeClient` in-memory state.
31
+ //! `ConPtyBackend` is Send+Sync; the token field is behind
32
+ //! `parking_lot::Mutex` and is NEVER handed out via a getter that
33
+ //! could feed serialisation.
34
+ //! - **C-2 kill_server**: this backend's `kill_server` closes the
35
+ //! pipe + tells the shim to `Shutdown`. Semantics are per-workspace
36
+ //! (each `ConPtyBackend` owns exactly one team shim), so callers who
37
+ //! invoke `kill_server` in a `KillDecision::KillWholeServer` branch
38
+ //! still get the same "tear down this workspace's transport" effect
39
+ //! they get from tmux.
40
+ //! - **C-3 stale event**: when `state.shim_pid` disagrees with what
41
+ //! `hello` returns, the backend emits `conpty_transport.shim_pid_stale`
42
+ //! BEFORE returning `MuxUnavailable`. Handled in
43
+ //! `PipeClient::ensure_hello` (Phase 1b).
44
+ //! - **C-5 pipe_token_mismatch**: the `PipeClient::request` inspection
45
+ //! of the response's `ProtocolError::PipeTokenMismatch` translates
46
+ //! directly to `TransportError::MuxUnavailable`. The client MUST NOT
47
+ //! silently rotate its own token.
48
+
49
+ use std::collections::BTreeMap;
50
+ use std::path::Path;
51
+ use std::sync::atomic::{AtomicU64, Ordering};
52
+
53
+ use crate::model::enums::PaneLiveness;
54
+ use crate::transport::{
55
+ AttachOutcome, BackendKind, CaptureRange, CapturedText, InjectPayload, InjectReport,
56
+ InjectStage, InjectVerification, Key, PaneField, PaneId, PaneInfo, SessionName,
57
+ SetEnvOutcome, SpawnResult, SubmitVerification, Target, Transport, TransportError,
58
+ TurnVerification, WindowName,
59
+ };
60
+
61
+ use conpty_transport::{
62
+ protocol, CaptureRequest, HelloResult, InjectRequest, Op, ProtocolError, Request, Response,
63
+ SpawnRequest, SpawnResult as ProtoSpawnResult,
64
+ };
65
+
66
+ /// The `Transport` implementation for the named ConPTY backend.
67
+ ///
68
+ /// The pipe client is optional so this struct can be constructed on
69
+ /// non-Windows hosts and in tests without a live shim; all trait methods
70
+ /// return `MuxUnavailable` in that case (honest degradation, not silent
71
+ /// success).
72
+ pub struct ConPtyBackend {
73
+ /// Canonical workspace-hash + team-key key — used only for the
74
+ /// `MuxUnavailable` diagnostic detail so operators see which shim
75
+ /// was expected.
76
+ workspace_hash: String,
77
+ team_key: String,
78
+ /// In-memory pipe_token learned from `hello`. CR C-1: never
79
+ /// persisted. Stored behind std::sync::Mutex so the backend can
80
+ /// be `Send + Sync` and shared across threads.
81
+ pipe_token: std::sync::Mutex<Option<String>>,
82
+ /// Present when a `PipeClient` is wired. When present, all
83
+ /// Required trait methods forward through the client; when absent,
84
+ /// they degrade to `MuxUnavailable` honest-fail.
85
+ pipe_client: Option<Box<dyn PipeClientTrait>>,
86
+ /// Monotonic request id counter — never persisted, never surfaced
87
+ /// to callers.
88
+ request_counter: AtomicU64,
89
+ }
90
+
91
+ impl ConPtyBackend {
92
+ /// New backend for `(workspace_hash, team_key)`. The pipe client is
93
+ /// left unset; callers wire it via `with_pipe_client`.
94
+ pub fn new(workspace_hash: impl Into<String>, team_key: impl Into<String>) -> Self {
95
+ Self {
96
+ workspace_hash: workspace_hash.into(),
97
+ team_key: team_key.into(),
98
+ pipe_token: std::sync::Mutex::new(None),
99
+ pipe_client: None,
100
+ request_counter: AtomicU64::new(0),
101
+ }
102
+ }
103
+
104
+ /// Wire a live PipeClient. Immediately performs `hello` to negotiate
105
+ /// the pipe_token; on failure, drops the client so subsequent calls
106
+ /// degrade honestly.
107
+ pub fn with_pipe_client(
108
+ mut self,
109
+ client: Box<dyn PipeClientTrait>,
110
+ ) -> Result<Self, TransportError> {
111
+ // Hello does NOT require token pre-match; use a placeholder.
112
+ let req = Request::new(
113
+ self.next_request_id(),
114
+ &self.workspace_hash,
115
+ &self.team_key,
116
+ "PENDING",
117
+ Op::Hello,
118
+ );
119
+ let resp = client.request(&req)?;
120
+ if !resp.ok {
121
+ return Err(TransportError::MuxUnavailable {
122
+ backend: BackendKind::ConPty,
123
+ detail: format!("hello failed: {:?}", resp.error),
124
+ });
125
+ }
126
+ let hello: HelloResult = serde_json::from_value(resp.result)
127
+ .map_err(|e| TransportError::MuxUnavailable {
128
+ backend: BackendKind::ConPty,
129
+ detail: format!("hello response malformed: {e}"),
130
+ })?;
131
+ *self.pipe_token.lock().unwrap() = Some(hello.pipe_token);
132
+ self.pipe_client = Some(client);
133
+ Ok(self)
134
+ }
135
+
136
+ fn next_request_id(&self) -> String {
137
+ let n = self.request_counter.fetch_add(1, Ordering::Relaxed);
138
+ format!("req-{n}")
139
+ }
140
+
141
+ fn build_request(&self, op: Op, payload: serde_json::Value) -> Result<Request, TransportError> {
142
+ let token = self
143
+ .pipe_token
144
+ .lock()
145
+ .unwrap()
146
+ .clone()
147
+ .ok_or_else(|| self.mux_unavailable("hello_never_completed"))?;
148
+ Ok(Request::new(
149
+ self.next_request_id(),
150
+ &self.workspace_hash,
151
+ &self.team_key,
152
+ token,
153
+ op,
154
+ )
155
+ .with_payload(payload))
156
+ }
157
+
158
+ fn dispatch(&self, op: Op, payload: serde_json::Value) -> Result<Response, TransportError> {
159
+ let Some(client) = self.pipe_client.as_ref() else {
160
+ return Err(self.mux_unavailable(&format!("{op:?}").to_lowercase()));
161
+ };
162
+ let req = self.build_request(op, payload)?;
163
+ let resp = client.request(&req)?;
164
+ if !resp.ok {
165
+ return Err(map_protocol_error(resp.error.as_ref()));
166
+ }
167
+ Ok(resp)
168
+ }
169
+
170
+ fn do_spawn(
171
+ &self,
172
+ session: &SessionName,
173
+ window: &WindowName,
174
+ argv: &[String],
175
+ cwd: &Path,
176
+ env: &BTreeMap<String, String>,
177
+ env_unset: &[String],
178
+ ) -> Result<SpawnResult, TransportError> {
179
+ let payload = serde_json::to_value(SpawnRequest {
180
+ session: session.as_str().to_string(),
181
+ window: window.as_str().to_string(),
182
+ argv: argv.to_vec(),
183
+ cwd: cwd.to_string_lossy().to_string(),
184
+ env: env.clone(),
185
+ env_unset: env_unset.to_vec(),
186
+ cols: 120,
187
+ rows: 30,
188
+ })
189
+ .map_err(|e| TransportError::Spawn {
190
+ backend: BackendKind::ConPty,
191
+ source: std::io::Error::other(e),
192
+ })?;
193
+ let resp = self.dispatch(Op::Spawn, payload)?;
194
+ let spawn: ProtoSpawnResult = serde_json::from_value(resp.result).map_err(|e| {
195
+ TransportError::Spawn {
196
+ backend: BackendKind::ConPty,
197
+ source: std::io::Error::other(e),
198
+ }
199
+ })?;
200
+ Ok(SpawnResult {
201
+ pane_id: PaneId::new(spawn.pane_id),
202
+ session: SessionName::new(spawn.session),
203
+ window: WindowName::new(spawn.window),
204
+ child_pid: spawn.child_pid,
205
+ })
206
+ }
207
+
208
+ /// Build a `MuxUnavailable` error explaining that no pipe client is
209
+ /// wired for this backend. The detail string carries the
210
+ /// `(workspace_hash, team_key)` so operators can correlate with
211
+ /// state.json's `transport.pipe_name`.
212
+ fn mux_unavailable(&self, op: &str) -> TransportError {
213
+ TransportError::MuxUnavailable {
214
+ backend: BackendKind::ConPty,
215
+ detail: format!(
216
+ "no_pipe_client (op={op}, workspace_hash={ws}, team_key={team}); \
217
+ no live shim wired — this is honest degradation, not silent success",
218
+ ws = self.workspace_hash,
219
+ team = self.team_key,
220
+ ),
221
+ }
222
+ }
223
+ }
224
+
225
+ /// Resolve a `Target` to a pane_id string for the shim protocol.
226
+ /// SessionWindow targets look up via `list_targets` are not supported
227
+ /// at the trait boundary today — the coordinator delivery path always
228
+ /// resolves worker IDs to `Target::Pane` before injection. If a
229
+ /// SessionWindow slips in, return a `TargetNotFound`.
230
+ fn pane_id_from_target(target: &Target) -> Result<String, TransportError> {
231
+ match target {
232
+ Target::Pane(p) => Ok(p.as_str().to_string()),
233
+ Target::SessionWindow { session, window } => Err(TransportError::TargetNotFound {
234
+ target: format!(
235
+ "conpty backend requires Target::Pane; got SessionWindow({}, {})",
236
+ session.as_str(),
237
+ window.as_str()
238
+ ),
239
+ }),
240
+ }
241
+ }
242
+
243
+ /// Map a `ProtocolError` returned by the shim to a `TransportError`
244
+ /// per design §Request Shape:332-338 + CR C-5.
245
+ fn map_protocol_error(err: Option<&ProtocolError>) -> TransportError {
246
+ let Some(err) = err else {
247
+ return TransportError::MuxUnavailable {
248
+ backend: BackendKind::ConPty,
249
+ detail: "shim returned ok=false without error payload".to_string(),
250
+ };
251
+ };
252
+ match err {
253
+ // CR C-5: token mismatch is honest MuxUnavailable — never silent
254
+ // retry with a rotated token.
255
+ ProtocolError::PipeTokenMismatch { message } => TransportError::MuxUnavailable {
256
+ backend: BackendKind::ConPty,
257
+ detail: format!("pipe_token_mismatch: {message}"),
258
+ },
259
+ ProtocolError::SchemaSkew { message, sent, expected } => TransportError::MuxUnavailable {
260
+ backend: BackendKind::ConPty,
261
+ detail: format!("schema_skew sent={sent} expected={expected}: {message}"),
262
+ },
263
+ ProtocolError::TargetNotFound { message } => {
264
+ TransportError::TargetNotFound { target: message.clone() }
265
+ }
266
+ ProtocolError::Spawn { message } => TransportError::Spawn {
267
+ backend: BackendKind::ConPty,
268
+ source: std::io::Error::other(message.clone()),
269
+ },
270
+ ProtocolError::Inject { message, stage } => TransportError::Inject {
271
+ stage: match stage.as_str() {
272
+ "text" | "load_buffer" => InjectStage::LoadBuffer,
273
+ _ => InjectStage::Submit,
274
+ },
275
+ source: std::io::Error::other(message.clone()),
276
+ },
277
+ ProtocolError::Capture { message } => TransportError::Capture {
278
+ source: std::io::Error::other(message.clone()),
279
+ },
280
+ ProtocolError::ShimUnavailable { message } | ProtocolError::Other { message } => {
281
+ TransportError::MuxUnavailable {
282
+ backend: BackendKind::ConPty,
283
+ detail: message.clone(),
284
+ }
285
+ }
286
+ }
287
+ }
288
+
289
+ /// Object-safe pipe-client trait so `ConPtyBackend` can hold a boxed
290
+ /// client without depending on the concrete Windows named-pipe type.
291
+ /// Phase 1b implements this against a real `\\.\pipe\...` handle;
292
+ /// Phase 1a tests can implement it against an in-memory
293
+ /// `Cursor<Vec<u8>>` pair.
294
+ ///
295
+ /// The `request` method returns the raw `Response`; the backend layer
296
+ /// maps errors into `TransportError`.
297
+ pub trait PipeClientTrait: Send + Sync {
298
+ fn request(&self, req: &Request) -> Result<Response, TransportError>;
299
+ }
300
+
301
+ /// Wrap a `conpty_transport::PipeClient` (which returns `Response`
302
+ /// directly) into a `PipeClientTrait` (which returns
303
+ /// `Result<Response, TransportError>`). Used by team-agent's fake-worker
304
+ /// path to plug in the portable `LocalShimClient`.
305
+ pub struct PipeClientAdapter<C: conpty_transport::PipeClient>(pub C);
306
+
307
+ impl<C: conpty_transport::PipeClient> PipeClientTrait for PipeClientAdapter<C> {
308
+ fn request(&self, req: &Request) -> Result<Response, TransportError> {
309
+ Ok(self.0.request(req))
310
+ }
311
+ }
312
+
313
+ impl Transport for ConPtyBackend {
314
+ fn kind(&self) -> BackendKind {
315
+ BackendKind::ConPty
316
+ }
317
+
318
+ fn probes_real_tmux_socket_roots(&self) -> bool {
319
+ // Design §Transport Boundary:102 — ConPTY exposes pipe name via
320
+ // a separate diagnostic field, NOT this tmux method.
321
+ false
322
+ }
323
+
324
+ fn tmux_endpoint(&self) -> Option<String> {
325
+ // Same — pipe name is exposed elsewhere, not here.
326
+ None
327
+ }
328
+
329
+ fn spawn_first(
330
+ &self,
331
+ session: &SessionName,
332
+ window: &WindowName,
333
+ argv: &[String],
334
+ cwd: &Path,
335
+ env: &BTreeMap<String, String>,
336
+ ) -> Result<SpawnResult, TransportError> {
337
+ self.do_spawn(session, window, argv, cwd, env, &[])
338
+ }
339
+
340
+ fn spawn_into(
341
+ &self,
342
+ session: &SessionName,
343
+ window: &WindowName,
344
+ argv: &[String],
345
+ cwd: &Path,
346
+ env: &BTreeMap<String, String>,
347
+ ) -> Result<SpawnResult, TransportError> {
348
+ self.do_spawn(session, window, argv, cwd, env, &[])
349
+ }
350
+
351
+ fn spawn_first_with_env_unset(
352
+ &self,
353
+ session: &SessionName,
354
+ window: &WindowName,
355
+ argv: &[String],
356
+ cwd: &Path,
357
+ env: &BTreeMap<String, String>,
358
+ env_unset: &[String],
359
+ ) -> Result<SpawnResult, TransportError> {
360
+ self.do_spawn(session, window, argv, cwd, env, env_unset)
361
+ }
362
+
363
+ fn spawn_into_with_env_unset(
364
+ &self,
365
+ session: &SessionName,
366
+ window: &WindowName,
367
+ argv: &[String],
368
+ cwd: &Path,
369
+ env: &BTreeMap<String, String>,
370
+ env_unset: &[String],
371
+ ) -> Result<SpawnResult, TransportError> {
372
+ self.do_spawn(session, window, argv, cwd, env, env_unset)
373
+ }
374
+
375
+ fn inject(
376
+ &self,
377
+ target: &Target,
378
+ payload: &InjectPayload,
379
+ submit: Key,
380
+ _bracketed: bool,
381
+ ) -> Result<InjectReport, TransportError> {
382
+ let pane_id = pane_id_from_target(target)?;
383
+ let text = payload.text().unwrap_or("").to_string();
384
+ let submit_key = match submit {
385
+ Key::Enter => Some("enter".to_string()),
386
+ Key::Up => Some("up".to_string()),
387
+ Key::Down => Some("down".to_string()),
388
+ Key::Left => Some("left".to_string()),
389
+ Key::Right => Some("right".to_string()),
390
+ Key::Escape => Some("escape".to_string()),
391
+ Key::CtrlC => Some("ctrl_c".to_string()),
392
+ Key::Char(_) | Key::CancelMode => None,
393
+ };
394
+ let payload_json = serde_json::to_value(InjectRequest {
395
+ pane_id,
396
+ text,
397
+ submit_key,
398
+ bracketed: _bracketed,
399
+ })
400
+ .map_err(|e| TransportError::Inject {
401
+ stage: InjectStage::LoadBuffer,
402
+ source: std::io::Error::other(e),
403
+ })?;
404
+ self.dispatch(Op::Inject, payload_json)?;
405
+ // Design §SubmitVerification:346 — text + CR = EnterSentWithoutPlaceholderCheck.
406
+ Ok(InjectReport {
407
+ stage_reached: InjectStage::Submit,
408
+ inject_verification: if matches!(payload, InjectPayload::Empty) {
409
+ InjectVerification::NoToken
410
+ } else {
411
+ InjectVerification::CaptureContainsToken
412
+ },
413
+ submit_verification: match submit {
414
+ Key::Enter => SubmitVerification::EnterSentWithoutPlaceholderCheck,
415
+ other => SubmitVerification::KeySentAfterVisibleToken { key: other },
416
+ },
417
+ turn_verification: TurnVerification::NotYetObserved,
418
+ attempts: 1,
419
+ submit_diagnostics: None,
420
+ })
421
+ }
422
+
423
+ fn send_keys(&self, _target: &Target, _keys: &[Key]) -> Result<(), TransportError> {
424
+ self.dispatch(Op::SendKeys, serde_json::Value::Null)?;
425
+ Ok(())
426
+ }
427
+
428
+ fn capture(
429
+ &self,
430
+ target: &Target,
431
+ range: CaptureRange,
432
+ ) -> Result<CapturedText, TransportError> {
433
+ let pane_id = pane_id_from_target(target)?;
434
+ let range_str = match range {
435
+ CaptureRange::Full => "full".to_string(),
436
+ CaptureRange::Head(n) => format!("head:{n}"),
437
+ CaptureRange::Tail(n) => format!("tail:{n}"),
438
+ };
439
+ let payload = serde_json::to_value(CaptureRequest {
440
+ pane_id,
441
+ range: range_str,
442
+ })
443
+ .map_err(|e| TransportError::Capture {
444
+ source: std::io::Error::other(e),
445
+ })?;
446
+ let resp = self.dispatch(Op::Capture, payload)?;
447
+ let cap: protocol::CaptureResult = serde_json::from_value(resp.result).map_err(|e| {
448
+ TransportError::Capture {
449
+ source: std::io::Error::other(e),
450
+ }
451
+ })?;
452
+ Ok(CapturedText {
453
+ text: cap.text,
454
+ range,
455
+ })
456
+ }
457
+
458
+ fn query(&self, _target: &Target, _field: PaneField) -> Result<Option<String>, TransportError> {
459
+ Ok(None)
460
+ }
461
+
462
+ fn liveness(&self, pane: &PaneId) -> Result<PaneLiveness, TransportError> {
463
+ let resp = self.dispatch(
464
+ Op::Liveness,
465
+ serde_json::json!({"pane_id": pane.as_str()}),
466
+ )?;
467
+ let known = resp.result["known"].as_bool().unwrap_or(false);
468
+ let alive = resp.result["alive"].as_bool().unwrap_or(false);
469
+ Ok(match (known, alive) {
470
+ (true, true) => PaneLiveness::Live,
471
+ (true, false) => PaneLiveness::Dead,
472
+ _ => PaneLiveness::Unknown,
473
+ })
474
+ }
475
+
476
+ fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
477
+ let resp = self.dispatch(Op::ListTargets, serde_json::Value::Null)?;
478
+ let empty = vec![];
479
+ let list = resp.result["targets"].as_array().unwrap_or(&empty);
480
+ Ok(list
481
+ .iter()
482
+ .map(|row| PaneInfo {
483
+ pane_id: PaneId::new(row["pane_id"].as_str().unwrap_or("").to_string()),
484
+ session: SessionName::new(row["session"].as_str().unwrap_or("").to_string()),
485
+ window_index: None,
486
+ window_name: row["window"]
487
+ .as_str()
488
+ .map(|w| WindowName::new(w.to_string())),
489
+ pane_index: None,
490
+ tty: None,
491
+ current_command: None,
492
+ current_path: None,
493
+ active: true,
494
+ pane_pid: row["child_pid"].as_u64().map(|p| p as u32),
495
+ leader_env: BTreeMap::new(),
496
+ })
497
+ .collect())
498
+ }
499
+
500
+ fn has_session(&self, session: &SessionName) -> Result<bool, TransportError> {
501
+ let resp = self.dispatch(
502
+ Op::HasSession,
503
+ serde_json::json!({"session": session.as_str()}),
504
+ )?;
505
+ Ok(resp.result["present"].as_bool().unwrap_or(false))
506
+ }
507
+
508
+ fn list_windows(&self, session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
509
+ let resp = self.dispatch(
510
+ Op::ListWindows,
511
+ serde_json::json!({"session": session.as_str()}),
512
+ )?;
513
+ let empty = vec![];
514
+ let list = resp.result["windows"].as_array().unwrap_or(&empty);
515
+ Ok(list
516
+ .iter()
517
+ .filter_map(|v| v.as_str())
518
+ .map(|s| WindowName::new(s.to_string()))
519
+ .collect())
520
+ }
521
+
522
+ fn set_session_env(
523
+ &self,
524
+ _session: &SessionName,
525
+ _key: &str,
526
+ _value: &str,
527
+ ) -> Result<SetEnvOutcome, TransportError> {
528
+ // Design §Transport Boundary:118 — ConPTY internalises env at
529
+ // spawn time; post-spawn mutation is NOT supported. This is a
530
+ // typed capability refusal, NOT an error, so it returns
531
+ // `Ok(InternalizedAtSpawn)` even without a live pipe client.
532
+ Ok(SetEnvOutcome::InternalizedAtSpawn)
533
+ }
534
+
535
+ fn has_pane(&self, pane: &PaneId) -> Result<Option<bool>, TransportError> {
536
+ let resp = self.dispatch(
537
+ Op::HasPane,
538
+ serde_json::json!({"pane_id": pane.as_str()}),
539
+ )?;
540
+ Ok(Some(resp.result["present"].as_bool().unwrap_or(false)))
541
+ }
542
+
543
+ fn kill_server(&self) -> Result<(), TransportError> {
544
+ // CR C-2: kill_server on ConPTY = shutdown this workspace's
545
+ // shim (per-team, NOT global). Existing tmux callers already
546
+ // gate the invocation on a KillDecision::KillWholeServer
547
+ // branch that scopes to the caller's transport instance, so
548
+ // per-workspace semantics naturally hold here.
549
+ self.dispatch(Op::Shutdown, serde_json::Value::Null)?;
550
+ Ok(())
551
+ }
552
+
553
+ fn kill_session(&self, session: &SessionName) -> Result<(), TransportError> {
554
+ self.dispatch(
555
+ Op::KillSession,
556
+ serde_json::json!({"session": session.as_str()}),
557
+ )?;
558
+ Ok(())
559
+ }
560
+
561
+ fn kill_window(&self, target: &Target) -> Result<(), TransportError> {
562
+ match target {
563
+ Target::Pane(pane) => {
564
+ self.dispatch(
565
+ Op::KillPane,
566
+ serde_json::json!({"pane_id": pane.as_str()}),
567
+ )?;
568
+ }
569
+ Target::SessionWindow { session, window } => {
570
+ self.dispatch(
571
+ Op::KillWindow,
572
+ serde_json::json!({
573
+ "session": session.as_str(),
574
+ "window": window.as_str(),
575
+ }),
576
+ )?;
577
+ }
578
+ }
579
+ Ok(())
580
+ }
581
+
582
+ fn kill_pane(&self, pane: &PaneId) -> Result<(), TransportError> {
583
+ self.dispatch(
584
+ Op::KillPane,
585
+ serde_json::json!({"pane_id": pane.as_str()}),
586
+ )?;
587
+ Ok(())
588
+ }
589
+
590
+ fn attach_session(&self, _session: &SessionName) -> Result<AttachOutcome, TransportError> {
591
+ // Design §Transport Boundary:123 + §Visibility Without tmux attach:374
592
+ // — ConPTY has NO attach concept. Typed refusal, not error.
593
+ // This is honest and matches the tmux-only method's semantics.
594
+ Ok(AttachOutcome::Unsupported {
595
+ reason: "ConPTY worker backend has no interactive attach; \
596
+ use `team-agent capture` or the optional \
597
+ `windows-view` (Phase 5) for scrollback."
598
+ .to_string(),
599
+ })
600
+ }
601
+ }
602
+
603
+ #[cfg(test)]
604
+ mod tests {
605
+ use super::*;
606
+
607
+ #[test]
608
+ fn kind_is_conpty() {
609
+ let b = ConPtyBackend::new("wshash", "team-a");
610
+ assert_eq!(b.kind(), BackendKind::ConPty);
611
+ }
612
+
613
+ #[test]
614
+ fn tmux_specific_methods_return_none_or_false() {
615
+ let b = ConPtyBackend::new("wshash", "team-a");
616
+ // Design §Transport Boundary:102 — ConPTY MUST NOT lie by
617
+ // reporting a fake tmux socket.
618
+ assert_eq!(b.tmux_endpoint(), None);
619
+ assert!(!b.probes_real_tmux_socket_roots());
620
+ }
621
+
622
+ #[test]
623
+ fn attach_session_returns_typed_unsupported() {
624
+ // Design §Transport Boundary:123 anchor. The refusal is
625
+ // `AttachOutcome::Unsupported`, NOT a `TransportError`.
626
+ let b = ConPtyBackend::new("wshash", "team-a");
627
+ let out = b.attach_session(&SessionName::new("s")).unwrap();
628
+ match out {
629
+ AttachOutcome::Unsupported { reason } => {
630
+ assert!(!reason.is_empty(), "reason must be non-empty");
631
+ }
632
+ other => panic!("expected Unsupported, got {other:?}"),
633
+ }
634
+ }
635
+
636
+ #[test]
637
+ fn set_session_env_returns_internalized_at_spawn() {
638
+ // Design §Transport Boundary:118. Typed refusal, not error.
639
+ let b = ConPtyBackend::new("wshash", "team-a");
640
+ let out = b
641
+ .set_session_env(&SessionName::new("s"), "KEY", "value")
642
+ .unwrap();
643
+ assert_eq!(out, SetEnvOutcome::InternalizedAtSpawn);
644
+ }
645
+
646
+ #[test]
647
+ fn no_pipe_client_returns_honest_mux_unavailable_not_success() {
648
+ // MUST-NOT-13 + CR C-3: with no pipe client wired we must NOT
649
+ // silently pretend the spawn/inject/capture succeeded.
650
+ //
651
+ // Each backend method dispatches through the underlying protocol
652
+ // `Op::*`; when no client is wired the `mux_unavailable` diag
653
+ // records the lowercased op discriminant ("spawn", "inject",
654
+ // etc.). Every method must therefore surface a distinct-shape
655
+ // MuxUnavailable, not silently swallow the request.
656
+ let b = ConPtyBackend::new("wshash", "team-a");
657
+ let errs = [
658
+ b.spawn_first(
659
+ &SessionName::new("s"),
660
+ &WindowName::new("w"),
661
+ &[],
662
+ Path::new("/tmp"),
663
+ &BTreeMap::new(),
664
+ )
665
+ .unwrap_err(),
666
+ b.spawn_into(
667
+ &SessionName::new("s"),
668
+ &WindowName::new("w"),
669
+ &[],
670
+ Path::new("/tmp"),
671
+ &BTreeMap::new(),
672
+ )
673
+ .unwrap_err(),
674
+ b.inject(
675
+ &Target::Pane(PaneId::new("conpty:test")),
676
+ &InjectPayload::Empty,
677
+ Key::Enter,
678
+ false,
679
+ )
680
+ .unwrap_err(),
681
+ b.capture(
682
+ &Target::Pane(PaneId::new("conpty:test")),
683
+ CaptureRange::Tail(80),
684
+ )
685
+ .unwrap_err(),
686
+ b.list_targets().unwrap_err(),
687
+ b.kill_session(&SessionName::new("s")).unwrap_err(),
688
+ ];
689
+ for err in errs {
690
+ match err {
691
+ TransportError::MuxUnavailable { backend, detail } => {
692
+ assert_eq!(backend, BackendKind::ConPty);
693
+ assert!(
694
+ detail.contains("no_pipe_client"),
695
+ "detail must anchor on `no_pipe_client` tag, got {detail}"
696
+ );
697
+ assert!(
698
+ detail.contains("workspace_hash=wshash"),
699
+ "detail must include workspace_hash, got {detail}"
700
+ );
701
+ assert!(
702
+ detail.contains("team_key=team-a"),
703
+ "detail must include team_key, got {detail}"
704
+ );
705
+ }
706
+ other => panic!("expected MuxUnavailable, got {other:?}"),
707
+ }
708
+ }
709
+ }
710
+ }