@team-agent/installer 0.5.1 → 0.5.3

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 (55) hide show
  1. package/Cargo.lock +120 -9
  2. package/Cargo.toml +2 -2
  3. package/crates/team-agent/Cargo.toml +21 -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 +79 -1
  9. package/crates/team-agent/src/cli/mod.rs +190 -80
  10. package/crates/team-agent/src/cli/named_address.rs +111 -0
  11. package/crates/team-agent/src/cli/send.rs +98 -3
  12. package/crates/team-agent/src/cli/status_port.rs +44 -6
  13. package/crates/team-agent/src/cli/tests/named_address.rs +135 -0
  14. package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
  15. package/crates/team-agent/src/cli/types.rs +17 -0
  16. package/crates/team-agent/src/codex_app_server.rs +549 -0
  17. package/crates/team-agent/src/conpty/backend.rs +822 -0
  18. package/crates/team-agent/src/conpty/mod.rs +32 -0
  19. package/crates/team-agent/src/coordinator/backoff.rs +132 -7
  20. package/crates/team-agent/src/coordinator/conpty_shim.rs +730 -0
  21. package/crates/team-agent/src/coordinator/health.rs +97 -11
  22. package/crates/team-agent/src/coordinator/mod.rs +8 -0
  23. package/crates/team-agent/src/coordinator/tests/daemon.rs +2 -1
  24. package/crates/team-agent/src/coordinator/tests/tick_core.rs +6 -0
  25. package/crates/team-agent/src/diagnose/orphans.rs +29 -10
  26. package/crates/team-agent/src/leader/lease.rs +144 -7
  27. package/crates/team-agent/src/leader/owner_bind.rs +5 -2
  28. package/crates/team-agent/src/leader/provider_attribution.rs +19 -34
  29. package/crates/team-agent/src/leader/start.rs +18 -5
  30. package/crates/team-agent/src/leader/tests/lease_api.rs +179 -0
  31. package/crates/team-agent/src/lib.rs +36 -0
  32. package/crates/team-agent/src/lifecycle/launch.rs +207 -94
  33. package/crates/team-agent/src/lifecycle/lock.rs +40 -33
  34. package/crates/team-agent/src/lifecycle/restart/agent.rs +10 -18
  35. package/crates/team-agent/src/lifecycle/restart/common.rs +70 -0
  36. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +7 -0
  37. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +7 -0
  38. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +63 -0
  39. package/crates/team-agent/src/mcp_server/wire.rs +6 -7
  40. package/crates/team-agent/src/messaging/delivery.rs +329 -9
  41. package/crates/team-agent/src/messaging/leader_receiver.rs +17 -138
  42. package/crates/team-agent/src/messaging/mod.rs +2 -2
  43. package/crates/team-agent/src/messaging/results.rs +78 -21
  44. package/crates/team-agent/src/messaging/tests/runtime.rs +237 -0
  45. package/crates/team-agent/src/packaging/tests.rs +41 -6
  46. package/crates/team-agent/src/packaging/types.rs +31 -3
  47. package/crates/team-agent/src/platform/argv.rs +324 -0
  48. package/crates/team-agent/src/platform/errors.rs +95 -0
  49. package/crates/team-agent/src/platform/file_lock.rs +418 -0
  50. package/crates/team-agent/src/platform/mod.rs +66 -0
  51. package/crates/team-agent/src/platform/process.rs +555 -0
  52. package/crates/team-agent/src/state/persist.rs +205 -25
  53. package/crates/team-agent/src/tmux_backend.rs +94 -13
  54. package/crates/team-agent/src/transport_factory.rs +751 -0
  55. package/package.json +4 -4
@@ -0,0 +1,822 @@
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
+ ///
86
+ /// 0.5.x CR C-5 fix (leader msg_a89b5a03bbf0): behind a Mutex so
87
+ /// the lazy `ensure_pipe_client` path can populate it on first
88
+ /// Op call. Prior shape kept it in `Option<...>` outside a lock,
89
+ /// which was fine when the factory pre-wired via
90
+ /// `with_pipe_client` but is racy under lazy semantics.
91
+ pipe_client: std::sync::Mutex<Option<Box<dyn PipeClientTrait>>>,
92
+ /// Optional pipe-name hint used by lazy connect. The factory
93
+ /// stashes this when it sees `state.transport.shim.pipe_name`
94
+ /// (Batch 5+), and `ensure_pipe_client` uses it on first Op that
95
+ /// needs the pipe. `None` = factory had no hint → backend stays
96
+ /// unwired.
97
+ pipe_hint: Option<String>,
98
+ /// Monotonic request id counter — never persisted, never surfaced
99
+ /// to callers.
100
+ request_counter: AtomicU64,
101
+ }
102
+
103
+ impl ConPtyBackend {
104
+ /// New backend for `(workspace_hash, team_key)`. The pipe client is
105
+ /// left unset; callers wire it via `with_pipe_client` (Batch 6
106
+ /// direct-handoff) or `with_pipe_hint` (Batch 5+ lazy connect).
107
+ pub fn new(workspace_hash: impl Into<String>, team_key: impl Into<String>) -> Self {
108
+ Self {
109
+ workspace_hash: workspace_hash.into(),
110
+ team_key: team_key.into(),
111
+ pipe_token: std::sync::Mutex::new(None),
112
+ pipe_client: std::sync::Mutex::new(None),
113
+ pipe_hint: None,
114
+ request_counter: AtomicU64::new(0),
115
+ }
116
+ }
117
+
118
+ /// 0.5.x CR C-5 fix (leader msg_a89b5a03bbf0): stash a pipe-name
119
+ /// hint for lazy connect. This is what the factory uses in
120
+ /// `build_conpty` — no I/O at resolve time; the connect is
121
+ /// deferred to the first Op that needs the pipe.
122
+ ///
123
+ /// Idempotent: calling twice with the same hint is a no-op;
124
+ /// calling with a different hint overrides the previous. The
125
+ /// factory calls this ONCE per resolve.
126
+ pub fn with_pipe_hint(mut self, pipe_name: impl Into<String>) -> Self {
127
+ self.pipe_hint = Some(pipe_name.into());
128
+ self
129
+ }
130
+
131
+ /// Read the current pipe-name hint (for factory logic that
132
+ /// derives a fallback hint from env override).
133
+ pub fn pipe_hint_ref(&self) -> Option<&str> {
134
+ self.pipe_hint.as_deref()
135
+ }
136
+
137
+ /// Wire a live PipeClient. Immediately performs `hello` to negotiate
138
+ /// the pipe_token; on failure, drops the client so subsequent calls
139
+ /// degrade honestly.
140
+ ///
141
+ /// Used by `lifecycle/launch.rs` Batch 6 direct-handoff path
142
+ /// (quick-start conpty spawn on Windows), where the coordinator
143
+ /// already has a Hello-connected client from
144
+ /// `coordinator::conpty_shim::spawn_shim_and_handshake`. The
145
+ /// client becomes the backend's initial `pipe_client` cache
146
+ /// value, unifying with the lazy path (subsequent Ops just find
147
+ /// a wired client and skip the lazy `ensure_pipe_client` cost).
148
+ pub fn with_pipe_client(
149
+ self,
150
+ client: Box<dyn PipeClientTrait>,
151
+ ) -> Result<Self, TransportError> {
152
+ // Hello does NOT require token pre-match; use a placeholder.
153
+ let req = Request::new(
154
+ self.next_request_id(),
155
+ &self.workspace_hash,
156
+ &self.team_key,
157
+ "PENDING",
158
+ Op::Hello,
159
+ );
160
+ let resp = client.request(&req)?;
161
+ if !resp.ok {
162
+ return Err(TransportError::MuxUnavailable {
163
+ backend: BackendKind::ConPty,
164
+ detail: format!("hello failed: {:?}", resp.error),
165
+ });
166
+ }
167
+ let hello: HelloResult = serde_json::from_value(resp.result)
168
+ .map_err(|e| TransportError::MuxUnavailable {
169
+ backend: BackendKind::ConPty,
170
+ detail: format!("hello response malformed: {e}"),
171
+ })?;
172
+ *self.pipe_token.lock().unwrap() = Some(hello.pipe_token);
173
+ *self.pipe_client.lock().unwrap() = Some(client);
174
+ Ok(self)
175
+ }
176
+
177
+ /// 0.5.x CR C-5 fix (leader msg_a89b5a03bbf0): the lazy-connect
178
+ /// helper. Called from `dispatch` on the first Op that needs the
179
+ /// pipe.
180
+ ///
181
+ /// Semantics:
182
+ /// 1. If `pipe_client` is already set (Batch 6 direct-handoff or
183
+ /// a prior lazy connect), return Ok immediately.
184
+ /// 2. Else if `pipe_hint` is set, attempt
185
+ /// `NamedPipeClient::connect(&hint, 2000)` (the same 2000ms
186
+ /// timeout the factory used pre-C-5-fix — leader constraint
187
+ /// "沿用 2000ms 语义"), perform Hello, cache the client.
188
+ /// 3. Else return `MuxUnavailable::no_pipe_client`.
189
+ ///
190
+ /// On Unix this fn is a no-op stub — `NamedPipeClient` doesn't
191
+ /// exist there. Callers that reach this path on Unix already
192
+ /// have `pipe_client == None` and fall to the error branch,
193
+ /// which is the pre-C-5-fix behavior.
194
+ fn ensure_pipe_client(&self) -> Result<(), TransportError> {
195
+ // Fast path: already wired.
196
+ if self.pipe_client.lock().unwrap().is_some() {
197
+ return Ok(());
198
+ }
199
+ // Lazy path: need a hint + Windows backend.
200
+ #[cfg(windows)]
201
+ {
202
+ let Some(pipe_name) = self.pipe_hint.as_ref() else {
203
+ return Err(self.mux_unavailable("no_pipe_hint"));
204
+ };
205
+ let client = conpty_transport::NamedPipeClient::connect(pipe_name, 2000)
206
+ .map_err(|e| self.mux_unavailable(&format!("connect_failed: {e}")))?;
207
+ let adapter = crate::conpty::PipeClientAdapter(client);
208
+ // Perform Hello inline to negotiate pipe_token.
209
+ let req = Request::new(
210
+ self.next_request_id(),
211
+ &self.workspace_hash,
212
+ &self.team_key,
213
+ "PENDING",
214
+ Op::Hello,
215
+ );
216
+ let resp = adapter.request(&req)?;
217
+ if !resp.ok {
218
+ return Err(TransportError::MuxUnavailable {
219
+ backend: BackendKind::ConPty,
220
+ detail: format!("hello_failed: {:?}", resp.error),
221
+ });
222
+ }
223
+ let hello: HelloResult = serde_json::from_value(resp.result).map_err(|e| {
224
+ TransportError::MuxUnavailable {
225
+ backend: BackendKind::ConPty,
226
+ detail: format!("hello_response_malformed: {e}"),
227
+ }
228
+ })?;
229
+ *self.pipe_token.lock().unwrap() = Some(hello.pipe_token);
230
+ *self.pipe_client.lock().unwrap() = Some(Box::new(adapter));
231
+ Ok(())
232
+ }
233
+ #[cfg(not(windows))]
234
+ {
235
+ // Unix has no NamedPipeClient. If we got here without a
236
+ // wired client, degrade honestly.
237
+ Err(self.mux_unavailable("no_pipe_client"))
238
+ }
239
+ }
240
+
241
+ fn next_request_id(&self) -> String {
242
+ let n = self.request_counter.fetch_add(1, Ordering::Relaxed);
243
+ format!("req-{n}")
244
+ }
245
+
246
+ fn build_request(&self, op: Op, payload: serde_json::Value) -> Result<Request, TransportError> {
247
+ let token = self
248
+ .pipe_token
249
+ .lock()
250
+ .unwrap()
251
+ .clone()
252
+ .ok_or_else(|| self.mux_unavailable("hello_never_completed"))?;
253
+ Ok(Request::new(
254
+ self.next_request_id(),
255
+ &self.workspace_hash,
256
+ &self.team_key,
257
+ token,
258
+ op,
259
+ )
260
+ .with_payload(payload))
261
+ }
262
+
263
+ fn dispatch(&self, op: Op, payload: serde_json::Value) -> Result<Response, TransportError> {
264
+ // 0.5.x CR C-5 fix (leader msg_a89b5a03bbf0): trigger the
265
+ // lazy connect + Hello on first Op that needs the pipe. If a
266
+ // client was pre-wired (Batch 6 direct-handoff) or a prior
267
+ // Op already connected, `ensure_pipe_client` is a fast
268
+ // no-op.
269
+ self.ensure_pipe_client()?;
270
+ let req = self.build_request(op, payload)?;
271
+ let client_guard = self.pipe_client.lock().unwrap();
272
+ let client = client_guard
273
+ .as_ref()
274
+ .ok_or_else(|| self.mux_unavailable(&format!("{op:?}").to_lowercase()))?;
275
+ let resp = client.request(&req)?;
276
+ if !resp.ok {
277
+ return Err(map_protocol_error(resp.error.as_ref()));
278
+ }
279
+ Ok(resp)
280
+ }
281
+
282
+ fn do_spawn(
283
+ &self,
284
+ session: &SessionName,
285
+ window: &WindowName,
286
+ argv: &[String],
287
+ cwd: &Path,
288
+ env: &BTreeMap<String, String>,
289
+ env_unset: &[String],
290
+ ) -> Result<SpawnResult, TransportError> {
291
+ let payload = serde_json::to_value(SpawnRequest {
292
+ session: session.as_str().to_string(),
293
+ window: window.as_str().to_string(),
294
+ argv: argv.to_vec(),
295
+ cwd: cwd.to_string_lossy().to_string(),
296
+ env: env.clone(),
297
+ env_unset: env_unset.to_vec(),
298
+ cols: 120,
299
+ rows: 30,
300
+ })
301
+ .map_err(|e| TransportError::Spawn {
302
+ backend: BackendKind::ConPty,
303
+ source: std::io::Error::other(e),
304
+ })?;
305
+ let resp = self.dispatch(Op::Spawn, payload)?;
306
+ let spawn: ProtoSpawnResult = serde_json::from_value(resp.result).map_err(|e| {
307
+ TransportError::Spawn {
308
+ backend: BackendKind::ConPty,
309
+ source: std::io::Error::other(e),
310
+ }
311
+ })?;
312
+ Ok(SpawnResult {
313
+ pane_id: PaneId::new(spawn.pane_id),
314
+ session: SessionName::new(spawn.session),
315
+ window: WindowName::new(spawn.window),
316
+ child_pid: spawn.child_pid,
317
+ })
318
+ }
319
+
320
+ /// Build a `MuxUnavailable` error explaining that no pipe client is
321
+ /// wired for this backend. The detail string carries the
322
+ /// `(workspace_hash, team_key)` so operators can correlate with
323
+ /// state.json's `transport.pipe_name`.
324
+ fn mux_unavailable(&self, op: &str) -> TransportError {
325
+ TransportError::MuxUnavailable {
326
+ backend: BackendKind::ConPty,
327
+ detail: format!(
328
+ "no_pipe_client (op={op}, workspace_hash={ws}, team_key={team}); \
329
+ no live shim wired — this is honest degradation, not silent success",
330
+ ws = self.workspace_hash,
331
+ team = self.team_key,
332
+ ),
333
+ }
334
+ }
335
+ }
336
+
337
+ /// Resolve a `Target` to a pane_id string for the shim protocol.
338
+ /// SessionWindow targets look up via `list_targets` are not supported
339
+ /// at the trait boundary today — the coordinator delivery path always
340
+ /// resolves worker IDs to `Target::Pane` before injection. If a
341
+ /// SessionWindow slips in, return a `TargetNotFound`.
342
+ fn pane_id_from_target(target: &Target) -> Result<String, TransportError> {
343
+ match target {
344
+ Target::Pane(p) => Ok(p.as_str().to_string()),
345
+ Target::SessionWindow { session, window } => Err(TransportError::TargetNotFound {
346
+ target: format!(
347
+ "conpty backend requires Target::Pane; got SessionWindow({}, {})",
348
+ session.as_str(),
349
+ window.as_str()
350
+ ),
351
+ }),
352
+ }
353
+ }
354
+
355
+ /// Map a `ProtocolError` returned by the shim to a `TransportError`
356
+ /// per design §Request Shape:332-338 + CR C-5.
357
+ fn map_protocol_error(err: Option<&ProtocolError>) -> TransportError {
358
+ let Some(err) = err else {
359
+ return TransportError::MuxUnavailable {
360
+ backend: BackendKind::ConPty,
361
+ detail: "shim returned ok=false without error payload".to_string(),
362
+ };
363
+ };
364
+ match err {
365
+ // CR C-5: token mismatch is honest MuxUnavailable — never silent
366
+ // retry with a rotated token.
367
+ ProtocolError::PipeTokenMismatch { message } => TransportError::MuxUnavailable {
368
+ backend: BackendKind::ConPty,
369
+ detail: format!("pipe_token_mismatch: {message}"),
370
+ },
371
+ ProtocolError::SchemaSkew { message, sent, expected } => TransportError::MuxUnavailable {
372
+ backend: BackendKind::ConPty,
373
+ detail: format!("schema_skew sent={sent} expected={expected}: {message}"),
374
+ },
375
+ ProtocolError::TargetNotFound { message } => {
376
+ TransportError::TargetNotFound { target: message.clone() }
377
+ }
378
+ ProtocolError::Spawn { message } => TransportError::Spawn {
379
+ backend: BackendKind::ConPty,
380
+ source: std::io::Error::other(message.clone()),
381
+ },
382
+ ProtocolError::Inject { message, stage } => TransportError::Inject {
383
+ stage: match stage.as_str() {
384
+ "text" | "load_buffer" => InjectStage::LoadBuffer,
385
+ _ => InjectStage::Submit,
386
+ },
387
+ source: std::io::Error::other(message.clone()),
388
+ },
389
+ ProtocolError::Capture { message } => TransportError::Capture {
390
+ source: std::io::Error::other(message.clone()),
391
+ },
392
+ ProtocolError::ShimUnavailable { message } | ProtocolError::Other { message } => {
393
+ TransportError::MuxUnavailable {
394
+ backend: BackendKind::ConPty,
395
+ detail: message.clone(),
396
+ }
397
+ }
398
+ }
399
+ }
400
+
401
+ /// Object-safe pipe-client trait so `ConPtyBackend` can hold a boxed
402
+ /// client without depending on the concrete Windows named-pipe type.
403
+ /// Phase 1b implements this against a real `\\.\pipe\...` handle;
404
+ /// Phase 1a tests can implement it against an in-memory
405
+ /// `Cursor<Vec<u8>>` pair.
406
+ ///
407
+ /// The `request` method returns the raw `Response`; the backend layer
408
+ /// maps errors into `TransportError`.
409
+ pub trait PipeClientTrait: Send + Sync {
410
+ fn request(&self, req: &Request) -> Result<Response, TransportError>;
411
+ }
412
+
413
+ /// Wrap a `conpty_transport::PipeClient` (which returns `Response`
414
+ /// directly) into a `PipeClientTrait` (which returns
415
+ /// `Result<Response, TransportError>`). Used by team-agent's fake-worker
416
+ /// path to plug in the portable `LocalShimClient`.
417
+ pub struct PipeClientAdapter<C: conpty_transport::PipeClient>(pub C);
418
+
419
+ impl<C: conpty_transport::PipeClient> PipeClientTrait for PipeClientAdapter<C> {
420
+ fn request(&self, req: &Request) -> Result<Response, TransportError> {
421
+ Ok(self.0.request(req))
422
+ }
423
+ }
424
+
425
+ impl Transport for ConPtyBackend {
426
+ fn kind(&self) -> BackendKind {
427
+ BackendKind::ConPty
428
+ }
429
+
430
+ fn probes_real_tmux_socket_roots(&self) -> bool {
431
+ // Design §Transport Boundary:102 — ConPTY exposes pipe name via
432
+ // a separate diagnostic field, NOT this tmux method.
433
+ false
434
+ }
435
+
436
+ fn tmux_endpoint(&self) -> Option<String> {
437
+ // Same — pipe name is exposed elsewhere, not here.
438
+ None
439
+ }
440
+
441
+ fn spawn_first(
442
+ &self,
443
+ session: &SessionName,
444
+ window: &WindowName,
445
+ argv: &[String],
446
+ cwd: &Path,
447
+ env: &BTreeMap<String, String>,
448
+ ) -> Result<SpawnResult, TransportError> {
449
+ self.do_spawn(session, window, argv, cwd, env, &[])
450
+ }
451
+
452
+ fn spawn_into(
453
+ &self,
454
+ session: &SessionName,
455
+ window: &WindowName,
456
+ argv: &[String],
457
+ cwd: &Path,
458
+ env: &BTreeMap<String, String>,
459
+ ) -> Result<SpawnResult, TransportError> {
460
+ self.do_spawn(session, window, argv, cwd, env, &[])
461
+ }
462
+
463
+ fn spawn_first_with_env_unset(
464
+ &self,
465
+ session: &SessionName,
466
+ window: &WindowName,
467
+ argv: &[String],
468
+ cwd: &Path,
469
+ env: &BTreeMap<String, String>,
470
+ env_unset: &[String],
471
+ ) -> Result<SpawnResult, TransportError> {
472
+ self.do_spawn(session, window, argv, cwd, env, env_unset)
473
+ }
474
+
475
+ fn spawn_into_with_env_unset(
476
+ &self,
477
+ session: &SessionName,
478
+ window: &WindowName,
479
+ argv: &[String],
480
+ cwd: &Path,
481
+ env: &BTreeMap<String, String>,
482
+ env_unset: &[String],
483
+ ) -> Result<SpawnResult, TransportError> {
484
+ self.do_spawn(session, window, argv, cwd, env, env_unset)
485
+ }
486
+
487
+ fn inject(
488
+ &self,
489
+ target: &Target,
490
+ payload: &InjectPayload,
491
+ submit: Key,
492
+ _bracketed: bool,
493
+ ) -> Result<InjectReport, TransportError> {
494
+ let pane_id = pane_id_from_target(target)?;
495
+ let text = payload.text().unwrap_or("").to_string();
496
+ let submit_key = match submit {
497
+ Key::Enter => Some("enter".to_string()),
498
+ Key::Up => Some("up".to_string()),
499
+ Key::Down => Some("down".to_string()),
500
+ Key::Left => Some("left".to_string()),
501
+ Key::Right => Some("right".to_string()),
502
+ Key::Escape => Some("escape".to_string()),
503
+ Key::CtrlC => Some("ctrl_c".to_string()),
504
+ Key::Char(_) | Key::CancelMode => None,
505
+ };
506
+ let payload_json = serde_json::to_value(InjectRequest {
507
+ pane_id,
508
+ text,
509
+ submit_key,
510
+ bracketed: _bracketed,
511
+ })
512
+ .map_err(|e| TransportError::Inject {
513
+ stage: InjectStage::LoadBuffer,
514
+ source: std::io::Error::other(e),
515
+ })?;
516
+ self.dispatch(Op::Inject, payload_json)?;
517
+ // Design §SubmitVerification:346 — text + CR = EnterSentWithoutPlaceholderCheck.
518
+ Ok(InjectReport {
519
+ stage_reached: InjectStage::Submit,
520
+ inject_verification: if matches!(payload, InjectPayload::Empty) {
521
+ InjectVerification::NoToken
522
+ } else {
523
+ InjectVerification::CaptureContainsToken
524
+ },
525
+ submit_verification: match submit {
526
+ Key::Enter => SubmitVerification::EnterSentWithoutPlaceholderCheck,
527
+ other => SubmitVerification::KeySentAfterVisibleToken { key: other },
528
+ },
529
+ turn_verification: TurnVerification::NotYetObserved,
530
+ attempts: 1,
531
+ submit_diagnostics: None,
532
+ })
533
+ }
534
+
535
+ fn send_keys(&self, _target: &Target, _keys: &[Key]) -> Result<(), TransportError> {
536
+ self.dispatch(Op::SendKeys, serde_json::Value::Null)?;
537
+ Ok(())
538
+ }
539
+
540
+ fn capture(
541
+ &self,
542
+ target: &Target,
543
+ range: CaptureRange,
544
+ ) -> Result<CapturedText, TransportError> {
545
+ let pane_id = pane_id_from_target(target)?;
546
+ let range_str = match range {
547
+ CaptureRange::Full => "full".to_string(),
548
+ CaptureRange::Head(n) => format!("head:{n}"),
549
+ CaptureRange::Tail(n) => format!("tail:{n}"),
550
+ };
551
+ let payload = serde_json::to_value(CaptureRequest {
552
+ pane_id,
553
+ range: range_str,
554
+ })
555
+ .map_err(|e| TransportError::Capture {
556
+ source: std::io::Error::other(e),
557
+ })?;
558
+ let resp = self.dispatch(Op::Capture, payload)?;
559
+ let cap: protocol::CaptureResult = serde_json::from_value(resp.result).map_err(|e| {
560
+ TransportError::Capture {
561
+ source: std::io::Error::other(e),
562
+ }
563
+ })?;
564
+ Ok(CapturedText {
565
+ text: cap.text,
566
+ range,
567
+ })
568
+ }
569
+
570
+ fn query(&self, _target: &Target, _field: PaneField) -> Result<Option<String>, TransportError> {
571
+ Ok(None)
572
+ }
573
+
574
+ fn liveness(&self, pane: &PaneId) -> Result<PaneLiveness, TransportError> {
575
+ let resp = self.dispatch(
576
+ Op::Liveness,
577
+ serde_json::json!({"pane_id": pane.as_str()}),
578
+ )?;
579
+ let known = resp.result["known"].as_bool().unwrap_or(false);
580
+ let alive = resp.result["alive"].as_bool().unwrap_or(false);
581
+ Ok(match (known, alive) {
582
+ (true, true) => PaneLiveness::Live,
583
+ (true, false) => PaneLiveness::Dead,
584
+ _ => PaneLiveness::Unknown,
585
+ })
586
+ }
587
+
588
+ fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
589
+ let resp = self.dispatch(Op::ListTargets, serde_json::Value::Null)?;
590
+ let empty = vec![];
591
+ let list = resp.result["targets"].as_array().unwrap_or(&empty);
592
+ Ok(list
593
+ .iter()
594
+ .map(|row| PaneInfo {
595
+ pane_id: PaneId::new(row["pane_id"].as_str().unwrap_or("").to_string()),
596
+ session: SessionName::new(row["session"].as_str().unwrap_or("").to_string()),
597
+ window_index: None,
598
+ window_name: row["window"]
599
+ .as_str()
600
+ .map(|w| WindowName::new(w.to_string())),
601
+ pane_index: None,
602
+ tty: None,
603
+ current_command: None,
604
+ current_path: None,
605
+ active: true,
606
+ pane_pid: row["child_pid"].as_u64().map(|p| p as u32),
607
+ leader_env: BTreeMap::new(),
608
+ })
609
+ .collect())
610
+ }
611
+
612
+ fn has_session(&self, session: &SessionName) -> Result<bool, TransportError> {
613
+ let resp = self.dispatch(
614
+ Op::HasSession,
615
+ serde_json::json!({"session": session.as_str()}),
616
+ )?;
617
+ Ok(resp.result["present"].as_bool().unwrap_or(false))
618
+ }
619
+
620
+ fn list_windows(&self, session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
621
+ let resp = self.dispatch(
622
+ Op::ListWindows,
623
+ serde_json::json!({"session": session.as_str()}),
624
+ )?;
625
+ let empty = vec![];
626
+ let list = resp.result["windows"].as_array().unwrap_or(&empty);
627
+ Ok(list
628
+ .iter()
629
+ .filter_map(|v| v.as_str())
630
+ .map(|s| WindowName::new(s.to_string()))
631
+ .collect())
632
+ }
633
+
634
+ fn set_session_env(
635
+ &self,
636
+ _session: &SessionName,
637
+ _key: &str,
638
+ _value: &str,
639
+ ) -> Result<SetEnvOutcome, TransportError> {
640
+ // Design §Transport Boundary:118 — ConPTY internalises env at
641
+ // spawn time; post-spawn mutation is NOT supported. This is a
642
+ // typed capability refusal, NOT an error, so it returns
643
+ // `Ok(InternalizedAtSpawn)` even without a live pipe client.
644
+ Ok(SetEnvOutcome::InternalizedAtSpawn)
645
+ }
646
+
647
+ fn has_pane(&self, pane: &PaneId) -> Result<Option<bool>, TransportError> {
648
+ let resp = self.dispatch(
649
+ Op::HasPane,
650
+ serde_json::json!({"pane_id": pane.as_str()}),
651
+ )?;
652
+ Ok(Some(resp.result["present"].as_bool().unwrap_or(false)))
653
+ }
654
+
655
+ fn kill_server(&self) -> Result<(), TransportError> {
656
+ // CR C-2: kill_server on ConPTY = shutdown this workspace's
657
+ // shim (per-team, NOT global). Existing tmux callers already
658
+ // gate the invocation on a KillDecision::KillWholeServer
659
+ // branch that scopes to the caller's transport instance, so
660
+ // per-workspace semantics naturally hold here.
661
+ self.dispatch(Op::Shutdown, serde_json::Value::Null)?;
662
+ Ok(())
663
+ }
664
+
665
+ fn kill_session(&self, session: &SessionName) -> Result<(), TransportError> {
666
+ self.dispatch(
667
+ Op::KillSession,
668
+ serde_json::json!({"session": session.as_str()}),
669
+ )?;
670
+ Ok(())
671
+ }
672
+
673
+ fn kill_window(&self, target: &Target) -> Result<(), TransportError> {
674
+ match target {
675
+ Target::Pane(pane) => {
676
+ self.dispatch(
677
+ Op::KillPane,
678
+ serde_json::json!({"pane_id": pane.as_str()}),
679
+ )?;
680
+ }
681
+ Target::SessionWindow { session, window } => {
682
+ self.dispatch(
683
+ Op::KillWindow,
684
+ serde_json::json!({
685
+ "session": session.as_str(),
686
+ "window": window.as_str(),
687
+ }),
688
+ )?;
689
+ }
690
+ }
691
+ Ok(())
692
+ }
693
+
694
+ fn kill_pane(&self, pane: &PaneId) -> Result<(), TransportError> {
695
+ self.dispatch(
696
+ Op::KillPane,
697
+ serde_json::json!({"pane_id": pane.as_str()}),
698
+ )?;
699
+ Ok(())
700
+ }
701
+
702
+ fn attach_session(&self, _session: &SessionName) -> Result<AttachOutcome, TransportError> {
703
+ // Design §Transport Boundary:123 + §Visibility Without tmux attach:374
704
+ // — ConPTY has NO attach concept. Typed refusal, not error.
705
+ // This is honest and matches the tmux-only method's semantics.
706
+ Ok(AttachOutcome::Unsupported {
707
+ reason: "ConPTY worker backend has no interactive attach; \
708
+ use `team-agent capture` or the optional \
709
+ `windows-view` (Phase 5) for scrollback."
710
+ .to_string(),
711
+ })
712
+ }
713
+ }
714
+
715
+ #[cfg(test)]
716
+ mod tests {
717
+ use super::*;
718
+
719
+ #[test]
720
+ fn kind_is_conpty() {
721
+ let b = ConPtyBackend::new("wshash", "team-a");
722
+ assert_eq!(b.kind(), BackendKind::ConPty);
723
+ }
724
+
725
+ #[test]
726
+ fn tmux_specific_methods_return_none_or_false() {
727
+ let b = ConPtyBackend::new("wshash", "team-a");
728
+ // Design §Transport Boundary:102 — ConPTY MUST NOT lie by
729
+ // reporting a fake tmux socket.
730
+ assert_eq!(b.tmux_endpoint(), None);
731
+ assert!(!b.probes_real_tmux_socket_roots());
732
+ }
733
+
734
+ #[test]
735
+ fn attach_session_returns_typed_unsupported() {
736
+ // Design §Transport Boundary:123 anchor. The refusal is
737
+ // `AttachOutcome::Unsupported`, NOT a `TransportError`.
738
+ let b = ConPtyBackend::new("wshash", "team-a");
739
+ let out = b.attach_session(&SessionName::new("s")).unwrap();
740
+ match out {
741
+ AttachOutcome::Unsupported { reason } => {
742
+ assert!(!reason.is_empty(), "reason must be non-empty");
743
+ }
744
+ other => panic!("expected Unsupported, got {other:?}"),
745
+ }
746
+ }
747
+
748
+ #[test]
749
+ fn set_session_env_returns_internalized_at_spawn() {
750
+ // Design §Transport Boundary:118. Typed refusal, not error.
751
+ let b = ConPtyBackend::new("wshash", "team-a");
752
+ let out = b
753
+ .set_session_env(&SessionName::new("s"), "KEY", "value")
754
+ .unwrap();
755
+ assert_eq!(out, SetEnvOutcome::InternalizedAtSpawn);
756
+ }
757
+
758
+ #[test]
759
+ fn no_pipe_client_returns_honest_mux_unavailable_not_success() {
760
+ // MUST-NOT-13 + CR C-3: with no pipe client wired we must NOT
761
+ // silently pretend the spawn/inject/capture succeeded.
762
+ //
763
+ // Each backend method dispatches through the underlying protocol
764
+ // `Op::*`; when no client is wired the `mux_unavailable` diag
765
+ // records the lowercased op discriminant ("spawn", "inject",
766
+ // etc.). Every method must therefore surface a distinct-shape
767
+ // MuxUnavailable, not silently swallow the request.
768
+ let b = ConPtyBackend::new("wshash", "team-a");
769
+ let errs = [
770
+ b.spawn_first(
771
+ &SessionName::new("s"),
772
+ &WindowName::new("w"),
773
+ &[],
774
+ Path::new("/tmp"),
775
+ &BTreeMap::new(),
776
+ )
777
+ .unwrap_err(),
778
+ b.spawn_into(
779
+ &SessionName::new("s"),
780
+ &WindowName::new("w"),
781
+ &[],
782
+ Path::new("/tmp"),
783
+ &BTreeMap::new(),
784
+ )
785
+ .unwrap_err(),
786
+ b.inject(
787
+ &Target::Pane(PaneId::new("conpty:test")),
788
+ &InjectPayload::Empty,
789
+ Key::Enter,
790
+ false,
791
+ )
792
+ .unwrap_err(),
793
+ b.capture(
794
+ &Target::Pane(PaneId::new("conpty:test")),
795
+ CaptureRange::Tail(80),
796
+ )
797
+ .unwrap_err(),
798
+ b.list_targets().unwrap_err(),
799
+ b.kill_session(&SessionName::new("s")).unwrap_err(),
800
+ ];
801
+ for err in errs {
802
+ match err {
803
+ TransportError::MuxUnavailable { backend, detail } => {
804
+ assert_eq!(backend, BackendKind::ConPty);
805
+ assert!(
806
+ detail.contains("no_pipe_client"),
807
+ "detail must anchor on `no_pipe_client` tag, got {detail}"
808
+ );
809
+ assert!(
810
+ detail.contains("workspace_hash=wshash"),
811
+ "detail must include workspace_hash, got {detail}"
812
+ );
813
+ assert!(
814
+ detail.contains("team_key=team-a"),
815
+ "detail must include team_key, got {detail}"
816
+ );
817
+ }
818
+ other => panic!("expected MuxUnavailable, got {other:?}"),
819
+ }
820
+ }
821
+ }
822
+ }