@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,632 @@
1
+ //! 0.5.x Phase 1d Batch 0: backend assembly factory.
2
+ //!
3
+ //! Truth sources (READ-ONLY):
4
+ //! - Design: `.team/artifacts/0.5.x-backend-assembly-factory-design.md`
5
+ //! - CR verdict: `.team/artifacts/0.5.x-backend-factory-cr-verdict.md`
6
+ //! (6 constraints; every one anchors into this module)
7
+ //!
8
+ //! ## Scope of Batch 0 (this commit)
9
+ //!
10
+ //! - Add the factory type + resolver ONLY. Zero caller migration.
11
+ //! - Ship the 5 independent tests the design lists (§Batch 0
12
+ //! Verification) + the C-1 grep guard (resolver has no
13
+ //! `if conpty|pty { .. tmux .. }` fallback branch) + the C-5 grep
14
+ //! guard (this file must never call `kill_server`, emit stale
15
+ //! events, or start a shim).
16
+ //! - C-6 gate: Batch 0 tests + guards must be GREEN before Batch 1
17
+ //! moves callers. This commit is the Batch 0 half; the very next
18
+ //! commit is Batch 1.
19
+ //!
20
+ //! ## CR anchors
21
+ //!
22
+ //! - **C-1** fail-closed × 2:
23
+ //! ① explicit `conpty` on an unsupported host / missing shim
24
+ //! surfaces `MuxUnavailable` — NEVER silent-downgrade to tmux.
25
+ //! ② `spec.runtime.backend = "pty"` returns
26
+ //! `UnsupportedSpecBackendLiteral` — NEVER auto-map ConPTY.
27
+ //! - **C-2** CLI override vs `state.transport.kind`: if both are set
28
+ //! and disagree, the factory REFUSES with `BackendConflict` (unless
29
+ //! fresh_switch is explicitly true — the caller path threads this
30
+ //! through when the user passes `--fresh`).
31
+ //! - **C-3** two-source conflict N38 events: when the resolver chooses
32
+ //! a backend and there's a shadow source that was ignored, the
33
+ //! `ResolvedTransport.notices` list carries structured entries the
34
+ //! caller emits into `events.jsonl`. Emission happens at the caller
35
+ //! boundary (not here) because the factory itself must have zero
36
+ //! side effects (C-5).
37
+ //! - **C-4** compact-status JSON stability is a separate grep-guard
38
+ //! test (`transport_factory_compact_status_guard.rs`); this module
39
+ //! just declares which fields are `--detail`-only.
40
+ //! - **C-5** factory purity: this file NEVER calls
41
+ //! `transport.kill_server(...)`, NEVER writes to `event_log`, NEVER
42
+ //! opens a pipe, NEVER starts a shim, NEVER rotates a token. Guard
43
+ //! test greps this file for those calls.
44
+ //! - **C-6** Batch 0 gate: the 5 core resolver tests + both C-1 grep
45
+ //! guards + the C-5 grep guard must pass before Batch 1's caller
46
+ //! migration begins. Enforced by CI + local test order.
47
+
48
+ use std::path::Path;
49
+
50
+ use crate::conpty::ConPtyBackend;
51
+ use crate::tmux_backend::TmuxBackend;
52
+ use crate::transport::{BackendKind, Transport};
53
+
54
+ /// User-visible backend selector on the CLI (`--backend tmux|conpty`).
55
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
56
+ pub enum RequestedTransportBackend {
57
+ Tmux,
58
+ ConPty,
59
+ }
60
+
61
+ impl RequestedTransportBackend {
62
+ pub fn parse_literal(s: &str) -> Option<Self> {
63
+ match s.trim().to_ascii_lowercase().as_str() {
64
+ "tmux" => Some(Self::Tmux),
65
+ "conpty" => Some(Self::ConPty),
66
+ _ => None,
67
+ }
68
+ }
69
+
70
+ pub fn as_wire(self) -> &'static str {
71
+ match self {
72
+ Self::Tmux => "tmux",
73
+ Self::ConPty => "conpty",
74
+ }
75
+ }
76
+ }
77
+
78
+ /// Which lifecycle site is asking the factory to resolve a transport.
79
+ /// Passed through so events + coordinator boot metadata can attribute
80
+ /// which caller made the choice. This has no effect on resolution
81
+ /// itself; it exists only for observability + C-3 event payloads.
82
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
83
+ pub enum TransportPurpose {
84
+ Launch,
85
+ LifecycleWorker,
86
+ Coordinator,
87
+ Status,
88
+ Diagnose,
89
+ Shutdown,
90
+ MessageDelivery,
91
+ }
92
+
93
+ /// Factory input. Every field except `workspace` and `purpose` is
94
+ /// optional; the resolution order (§Factory Design:141-149) picks the
95
+ /// first source that fires.
96
+ ///
97
+ /// The lifetimes tie each borrowed value to the caller so the factory
98
+ /// is a pure function of its inputs.
99
+ #[derive(Debug, Clone, Copy)]
100
+ pub struct TransportFactoryInput<'a> {
101
+ pub workspace: &'a Path,
102
+ pub team_key: Option<&'a str>,
103
+ pub state: Option<&'a serde_json::Value>,
104
+ pub spec_backend_literal: Option<&'a str>,
105
+ pub explicit_backend: Option<RequestedTransportBackend>,
106
+ pub purpose: TransportPurpose,
107
+ /// True only when the caller has confirmed the switch (via
108
+ /// `--fresh` or a similar explicit gate). Used to allow C-2's
109
+ /// otherwise-refuse path.
110
+ pub allow_backend_switch: bool,
111
+ }
112
+
113
+ impl<'a> TransportFactoryInput<'a> {
114
+ pub fn new(workspace: &'a Path, purpose: TransportPurpose) -> Self {
115
+ Self {
116
+ workspace,
117
+ team_key: None,
118
+ state: None,
119
+ spec_backend_literal: None,
120
+ explicit_backend: None,
121
+ purpose,
122
+ allow_backend_switch: false,
123
+ }
124
+ }
125
+
126
+ pub fn with_team_key(mut self, team_key: Option<&'a str>) -> Self {
127
+ self.team_key = team_key;
128
+ self
129
+ }
130
+
131
+ pub fn with_state(mut self, state: Option<&'a serde_json::Value>) -> Self {
132
+ self.state = state;
133
+ self
134
+ }
135
+
136
+ pub fn with_spec_backend_literal(mut self, literal: Option<&'a str>) -> Self {
137
+ self.spec_backend_literal = literal;
138
+ self
139
+ }
140
+
141
+ pub fn with_explicit_backend(mut self, override_: Option<RequestedTransportBackend>) -> Self {
142
+ self.explicit_backend = override_;
143
+ self
144
+ }
145
+
146
+ pub fn allow_backend_switch(mut self, allow: bool) -> Self {
147
+ self.allow_backend_switch = allow;
148
+ self
149
+ }
150
+ }
151
+
152
+ /// One-line trace of what the factory resolved and why. Emitted by the
153
+ /// caller (never by the factory itself — C-5) as
154
+ /// `transport.backend_selected`.
155
+ ///
156
+ /// The `source` field is a stable wire string so telemetry can pivot
157
+ /// on it. Values: `"cli"`, `"state.transport.kind"`,
158
+ /// `"legacy_tmux_endpoint"`, `"spec.runtime.backend"`, `"default"`.
159
+ ///
160
+ /// No `Clone` — the boxed `Transport` trait object is not cloneable.
161
+ pub struct ResolvedTransport {
162
+ pub backend: Box<dyn Transport>,
163
+ pub kind: BackendKind,
164
+ pub source: &'static str,
165
+ pub tmux_endpoint_used: Option<String>,
166
+ pub team_key: Option<String>,
167
+ /// N38 交底: extra events the caller should emit next to
168
+ /// `transport.backend_selected` so users can explain WHY a shadow
169
+ /// source was ignored (C-3).
170
+ pub notices: Vec<TransportNotice>,
171
+ }
172
+
173
+ impl std::fmt::Debug for ResolvedTransport {
174
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175
+ f.debug_struct("ResolvedTransport")
176
+ .field("kind", &self.kind)
177
+ .field("source", &self.source)
178
+ .field("tmux_endpoint_used", &self.tmux_endpoint_used)
179
+ .field("team_key", &self.team_key)
180
+ .field("notices", &self.notices)
181
+ .field("backend", &"<dyn Transport>")
182
+ .finish()
183
+ }
184
+ }
185
+
186
+ /// A single N38-style notice the caller should emit into events.jsonl
187
+ /// after choosing a backend. The factory returns them; it never emits
188
+ /// (C-5).
189
+ #[derive(Debug, Clone, PartialEq, Eq)]
190
+ pub struct TransportNotice {
191
+ pub event: &'static str,
192
+ pub payload: serde_json::Value,
193
+ }
194
+
195
+ /// Every way the factory can refuse honestly (MUST-NOT-13 + C-1/C-2).
196
+ #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
197
+ pub enum TransportFactoryError {
198
+ /// C-1 ①: explicit `conpty` was requested but a required precondition
199
+ /// isn't met (missing `team_key`, non-Windows host with no shim
200
+ /// wired, etc.). Callers MUST surface this as
201
+ /// `TransportError::MuxUnavailable` — NEVER silent-downgrade to
202
+ /// tmux.
203
+ #[error("conpty backend requires {reason}")]
204
+ ConPtyPreconditionUnmet { reason: String },
205
+
206
+ /// C-1 ②: `spec.runtime.backend = "pty"` was seen. The MVP does not
207
+ /// auto-map `pty` to ConPTY (design §Non-Goals:370). Callers must
208
+ /// treat this as a hard error.
209
+ #[error("unsupported spec.runtime.backend literal {literal:?}; only tmux|conpty are supported in Phase 1d")]
210
+ UnsupportedSpecBackendLiteral { literal: String },
211
+
212
+ /// C-2: CLI `--backend X` and `state.transport.kind=Y` disagree and
213
+ /// the caller did not pass `allow_backend_switch=true` (i.e. no
214
+ /// `--fresh`). Refuse rather than orphan existing workers whose
215
+ /// `pane_id` prefix matches the OTHER backend.
216
+ #[error(
217
+ "backend conflict: CLI requested {cli:?} but state.transport.kind={state:?}; \
218
+ switching would abandon existing {state:?} workers — pass --fresh to switch"
219
+ )]
220
+ BackendConflict {
221
+ cli: RequestedTransportBackend,
222
+ state: BackendKind,
223
+ },
224
+ }
225
+
226
+ /// Resolve a transport per the 5-layer order:
227
+ ///
228
+ /// 1. Explicit CLI override.
229
+ /// 2. `state.transport.kind`.
230
+ /// 3. Legacy `state.tmux_endpoint` / `state.tmux_socket` → tmux.
231
+ /// 4. Spec `runtime.backend` literal (`tmux` / `conpty`; `pty`
232
+ /// explicitly unsupported).
233
+ /// 5. Default → tmux workspace socket.
234
+ ///
235
+ /// The function returns `ResolvedTransport` for the success path or
236
+ /// `TransportFactoryError` for any refusal. **Never silently downgrades
237
+ /// conpty → tmux, never auto-maps pty → conpty, never runs
238
+ /// side-effects (open pipe, kill server, rotate token, emit event —
239
+ /// see C-5 guard test).**
240
+ pub fn resolve_transport(
241
+ input: TransportFactoryInput<'_>,
242
+ ) -> Result<ResolvedTransport, TransportFactoryError> {
243
+ let state_kind = input
244
+ .state
245
+ .and_then(|s| s.pointer("/transport/kind"))
246
+ .and_then(|v| v.as_str())
247
+ .and_then(RequestedTransportBackend::parse_literal);
248
+ // ── C-2 ────────────────────────────────────────────────────────
249
+ // If CLI override disagrees with state.transport.kind, refuse
250
+ // unless the caller opted-in via allow_backend_switch (--fresh).
251
+ // This is fail-closed BEFORE we build any backend so no side effect
252
+ // leaks.
253
+ if let (Some(cli), Some(state)) = (input.explicit_backend, state_kind) {
254
+ if cli != state && !input.allow_backend_switch {
255
+ return Err(TransportFactoryError::BackendConflict {
256
+ cli,
257
+ state: requested_to_kind(state),
258
+ });
259
+ }
260
+ }
261
+ // ── Layer 1: CLI override ─────────────────────────────────────
262
+ if let Some(requested) = input.explicit_backend {
263
+ return build_selected(&input, requested, "cli");
264
+ }
265
+ // ── Layer 2: state.transport.kind ─────────────────────────────
266
+ if let Some(requested) = state_kind {
267
+ return build_selected(&input, requested, "state.transport.kind");
268
+ }
269
+ // ── Layer 3: legacy tmux endpoint / socket ────────────────────
270
+ if let Some((_, _)) = crate::tmux_backend::runtime_tmux_endpoint_from_state_pub(input.state) {
271
+ return build_selected(&input, RequestedTransportBackend::Tmux, "legacy_tmux_endpoint");
272
+ }
273
+ // ── Layer 4: spec.runtime.backend literal ─────────────────────
274
+ if let Some(literal) = input.spec_backend_literal {
275
+ match literal.trim().to_ascii_lowercase().as_str() {
276
+ "tmux" => {
277
+ return build_selected(&input, RequestedTransportBackend::Tmux, "spec.runtime.backend");
278
+ }
279
+ "conpty" => {
280
+ return build_selected(&input, RequestedTransportBackend::ConPty, "spec.runtime.backend");
281
+ }
282
+ other => {
283
+ // C-1 ②: `pty` (and everything else) is NOT auto-mapped
284
+ // to ConPTY. Fail closed with a typed error.
285
+ return Err(TransportFactoryError::UnsupportedSpecBackendLiteral {
286
+ literal: other.to_string(),
287
+ });
288
+ }
289
+ }
290
+ }
291
+ // ── Layer 5: default → tmux workspace ─────────────────────────
292
+ build_selected(&input, RequestedTransportBackend::Tmux, "default")
293
+ }
294
+
295
+ fn requested_to_kind(r: RequestedTransportBackend) -> BackendKind {
296
+ match r {
297
+ RequestedTransportBackend::Tmux => BackendKind::Tmux,
298
+ RequestedTransportBackend::ConPty => BackendKind::ConPty,
299
+ }
300
+ }
301
+
302
+ fn build_selected(
303
+ input: &TransportFactoryInput<'_>,
304
+ requested: RequestedTransportBackend,
305
+ source: &'static str,
306
+ ) -> Result<ResolvedTransport, TransportFactoryError> {
307
+ match requested {
308
+ RequestedTransportBackend::Tmux => Ok(build_tmux(input, source)),
309
+ RequestedTransportBackend::ConPty => build_conpty(input, source),
310
+ }
311
+ }
312
+
313
+ fn build_tmux(input: &TransportFactoryInput<'_>, source: &'static str) -> ResolvedTransport {
314
+ // Preserve the existing runtime endpoint / workspace fallback shape
315
+ // so tmux callers see byte-equivalent behavior.
316
+ let selection = crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(
317
+ input.workspace,
318
+ input.state,
319
+ );
320
+ // C-3: if we chose tmux because of `state.transport.kind = tmux`
321
+ // OR `spec.runtime.backend = tmux` OR `default`, but a legacy tmux
322
+ // endpoint is *also* present, that is not a conflict (they
323
+ // agree). The conflict scenario in design §Two-Source Conflict
324
+ // Scene C is `legacy tmux + spec conpty` — order 3 wins, spec is
325
+ // ignored. Callers of the `spec.runtime.backend` path with a
326
+ // legacy tmux endpoint present take a different code path (they
327
+ // hit layer 3 first). So there is no notice to emit for tmux.
328
+ ResolvedTransport {
329
+ kind: BackendKind::Tmux,
330
+ source,
331
+ tmux_endpoint_used: selection.tmux_endpoint_used,
332
+ team_key: input.team_key.map(str::to_string),
333
+ backend: Box::new(selection.backend),
334
+ notices: Vec::new(),
335
+ }
336
+ }
337
+
338
+ fn build_conpty(
339
+ input: &TransportFactoryInput<'_>,
340
+ source: &'static str,
341
+ ) -> Result<ResolvedTransport, TransportFactoryError> {
342
+ // C-1 fail-closed: ConPTY needs a team_key. Missing → refuse.
343
+ // NEVER silent-downgrade to tmux; that would violate MUST-NOT-13.
344
+ let Some(team_key) = input.team_key else {
345
+ return Err(TransportFactoryError::ConPtyPreconditionUnmet {
346
+ reason: "team_key required for conpty (design §Factory Design:159-163)"
347
+ .to_string(),
348
+ });
349
+ };
350
+ // Build a workspace hash from the canonical workspace path using
351
+ // the same source of truth the tmux short-socket derivation uses,
352
+ // so the shim + tmux socket see identical workspace identity.
353
+ let workspace_hash = crate::tmux_backend::workspace_short_hash_pub(input.workspace);
354
+ // No pipe client is wired here (that's Batch 2/3 scope). Without
355
+ // a live client, `ConPtyBackend` degrades to `MuxUnavailable`
356
+ // honestly — which is exactly what C-1 requires when the caller
357
+ // asked for conpty on a host with no shim.
358
+ let backend = ConPtyBackend::new(workspace_hash, team_key);
359
+ let mut notices = Vec::new();
360
+ // C-3: if state has BOTH `transport.kind=conpty` and a legacy
361
+ // `tmux_endpoint`, tell the caller so they emit
362
+ // `transport.legacy_tmux_endpoint_ignored`. Same shape for the
363
+ // Scene C case (legacy tmux + spec conpty): if we're on the spec
364
+ // path we would have hit the legacy branch first, so this only
365
+ // fires when `state.transport.kind=conpty` explicitly overrode
366
+ // the legacy fields.
367
+ if let Some(state) = input.state {
368
+ if source == "state.transport.kind" {
369
+ if let Some(legacy) = state.get("tmux_endpoint").and_then(|v| v.as_str()) {
370
+ notices.push(TransportNotice {
371
+ event: "transport.legacy_tmux_endpoint_ignored",
372
+ payload: serde_json::json!({
373
+ "chosen_backend": "conpty",
374
+ "legacy_tmux_endpoint": legacy,
375
+ "reason": "state.transport.kind=conpty explicitly overrides legacy tmux endpoint",
376
+ }),
377
+ });
378
+ }
379
+ }
380
+ }
381
+ Ok(ResolvedTransport {
382
+ kind: BackendKind::ConPty,
383
+ source,
384
+ tmux_endpoint_used: None,
385
+ team_key: Some(team_key.to_string()),
386
+ backend: Box::new(backend),
387
+ notices,
388
+ })
389
+ }
390
+
391
+ // ─────────────────────────────────────────────────────────────────────────
392
+ // Tmux channel helpers.
393
+ //
394
+ // These are intentional tmux-specific entrypoints for callers that
395
+ // target a KNOWN tmux socket (external leader panes, orphan diagnosis,
396
+ // managed leader launcher). They are NOT part of the team backend
397
+ // selection surface. Callers grep for these names to signal intent.
398
+ // ─────────────────────────────────────────────────────────────────────────
399
+
400
+ /// 0.5.x Phase 1d Batch 3 read-path convenience: build a
401
+ /// `ResolvedTransport` for a plain workspace + optional state pair.
402
+ ///
403
+ /// This is the single entrypoint the coordinator boot / status /
404
+ /// diagnose read-only paths use. It follows the same 5-layer resolution
405
+ /// order as `resolve_transport`; it does NOT accept a CLI backend
406
+ /// override (read paths don't have one) — that avoids accidentally
407
+ /// widening the CLI surface at every reader.
408
+ ///
409
+ /// On `Err`, callers should fall back to a tmux workspace transport
410
+ /// (byte-equivalent to today's shape) and record the refusal so
411
+ /// telemetry stays honest.
412
+ pub fn resolve_read_only_transport(
413
+ workspace: &Path,
414
+ state: Option<&serde_json::Value>,
415
+ purpose: TransportPurpose,
416
+ ) -> Result<ResolvedTransport, TransportFactoryError> {
417
+ let input = TransportFactoryInput::new(workspace, purpose)
418
+ .with_state(state)
419
+ // Read paths never pass a team_key — they use whatever state
420
+ // says. If `state.transport.kind = conpty` but there is no
421
+ // team_key, factory refuses (C-1 ①), which is the same honest
422
+ // signal we want to surface to the coordinator/status reader.
423
+ .with_team_key(
424
+ state
425
+ .and_then(|s| s.get("teams"))
426
+ .and_then(|t| t.as_object())
427
+ .and_then(|obj| obj.keys().next().map(String::as_str)),
428
+ );
429
+ resolve_transport(input)
430
+ }
431
+
432
+ /// Tmux transport for a caller-supplied tmux endpoint literal. Used by
433
+ /// leader launcher, diagnose/orphans, and external leader pane
434
+ /// discovery — NOT for worker/team selection.
435
+ pub fn tmux_endpoint_transport(endpoint: &str) -> TmuxBackend {
436
+ TmuxBackend::for_tmux_endpoint(endpoint)
437
+ }
438
+
439
+ /// Tmux transport for the default (shared) socket. Same intent as
440
+ /// above — used by leader-side channel operations, never worker/team
441
+ /// selection.
442
+ pub fn tmux_default_transport() -> TmuxBackend {
443
+ TmuxBackend::new()
444
+ }
445
+
446
+ /// Tmux transport for the per-workspace socket. This is what the tmux
447
+ /// launch path uses when there is no persisted endpoint. Not for
448
+ /// generic worker/team selection — that goes through
449
+ /// `resolve_transport`.
450
+ pub fn tmux_workspace_transport(workspace: &Path) -> TmuxBackend {
451
+ TmuxBackend::for_workspace(workspace)
452
+ }
453
+
454
+ /// Tmux transport for a specific named tmux socket (e.g.
455
+ /// `ta-abcdef012345`). Used by orphan-scanner and diagnose paths that
456
+ /// intentionally enumerate the tmux socket roots — NOT for team
457
+ /// backend selection.
458
+ pub fn tmux_socket_name_transport(socket: &str) -> TmuxBackend {
459
+ TmuxBackend::for_socket_name(socket)
460
+ }
461
+
462
+ // ─────────────────────────────────────────────────────────────────────────
463
+ // Tests (§Batch 0 Verification, design.md:220-224).
464
+ // ─────────────────────────────────────────────────────────────────────────
465
+ #[cfg(test)]
466
+ mod tests {
467
+ use super::*;
468
+ use std::path::PathBuf;
469
+
470
+ fn ws() -> PathBuf {
471
+ std::env::temp_dir().join("ta-phase1d-factory-tests")
472
+ }
473
+
474
+ // Test #1 — no state/spec/override → tmux workspace backend.
475
+ #[test]
476
+ fn no_input_resolves_to_default_tmux_workspace_backend() {
477
+ let workspace = ws();
478
+ let input = TransportFactoryInput::new(&workspace, TransportPurpose::Launch);
479
+ let resolved = resolve_transport(input).expect("default path must succeed");
480
+ assert_eq!(resolved.kind, BackendKind::Tmux);
481
+ assert_eq!(resolved.source, "default");
482
+ assert!(resolved.notices.is_empty());
483
+ }
484
+
485
+ // Test #2 — legacy `state.tmux_endpoint` → tmux endpoint (order 3).
486
+ #[test]
487
+ fn legacy_tmux_endpoint_resolves_to_tmux_endpoint_backend() {
488
+ let workspace = ws();
489
+ let state = serde_json::json!({ "tmux_endpoint": "default" });
490
+ let input = TransportFactoryInput::new(&workspace, TransportPurpose::LifecycleWorker)
491
+ .with_state(Some(&state));
492
+ let resolved = resolve_transport(input).expect("legacy path must succeed");
493
+ assert_eq!(resolved.kind, BackendKind::Tmux);
494
+ assert_eq!(resolved.source, "legacy_tmux_endpoint");
495
+ }
496
+
497
+ // Test #3 — explicit conpty (via CLI) with team_key → ConPty.
498
+ #[test]
499
+ fn explicit_conpty_with_team_key_resolves_to_conpty() {
500
+ let workspace = ws();
501
+ let input = TransportFactoryInput::new(&workspace, TransportPurpose::LifecycleWorker)
502
+ .with_team_key(Some("team-a"))
503
+ .with_explicit_backend(Some(RequestedTransportBackend::ConPty));
504
+ let resolved = resolve_transport(input).expect("explicit conpty must succeed with team_key");
505
+ assert_eq!(resolved.kind, BackendKind::ConPty);
506
+ assert_eq!(resolved.source, "cli");
507
+ assert_eq!(resolved.team_key.as_deref(), Some("team-a"));
508
+ }
509
+
510
+ // Test #4 — explicit conpty WITHOUT team_key → refusal (C-1 ①).
511
+ #[test]
512
+ fn explicit_conpty_without_team_key_refuses_no_silent_fallback() {
513
+ let workspace = ws();
514
+ let input = TransportFactoryInput::new(&workspace, TransportPurpose::LifecycleWorker)
515
+ .with_explicit_backend(Some(RequestedTransportBackend::ConPty));
516
+ let err = resolve_transport(input).expect_err("conpty without team_key must refuse");
517
+ match err {
518
+ TransportFactoryError::ConPtyPreconditionUnmet { reason } => {
519
+ assert!(reason.contains("team_key"), "reason must mention team_key: {reason}");
520
+ }
521
+ other => panic!("expected ConPtyPreconditionUnmet, got {other:?}"),
522
+ }
523
+ }
524
+
525
+ // Test #5 — `pty` literal is NOT silently mapped to ConPTY (C-1 ②).
526
+ #[test]
527
+ fn pty_literal_is_not_silently_mapped_to_conpty() {
528
+ let workspace = ws();
529
+ let input = TransportFactoryInput::new(&workspace, TransportPurpose::Launch)
530
+ .with_team_key(Some("team-a"))
531
+ .with_spec_backend_literal(Some("pty"));
532
+ let err = resolve_transport(input)
533
+ .expect_err("pty literal must be refused, not auto-mapped");
534
+ match err {
535
+ TransportFactoryError::UnsupportedSpecBackendLiteral { literal } => {
536
+ assert_eq!(literal, "pty");
537
+ }
538
+ other => panic!("expected UnsupportedSpecBackendLiteral, got {other:?}"),
539
+ }
540
+ }
541
+
542
+ // C-2 supplementary — CLI override vs state disagreement refuses.
543
+ #[test]
544
+ fn cli_override_disagreeing_with_state_kind_refuses_unless_fresh() {
545
+ let workspace = ws();
546
+ let state = serde_json::json!({ "transport": { "kind": "conpty" } });
547
+ let input = TransportFactoryInput::new(&workspace, TransportPurpose::LifecycleWorker)
548
+ .with_team_key(Some("team-a"))
549
+ .with_state(Some(&state))
550
+ .with_explicit_backend(Some(RequestedTransportBackend::Tmux));
551
+ let err = resolve_transport(input).expect_err("disagreement must refuse");
552
+ match err {
553
+ TransportFactoryError::BackendConflict { cli, state } => {
554
+ assert_eq!(cli, RequestedTransportBackend::Tmux);
555
+ assert_eq!(state, BackendKind::ConPty);
556
+ }
557
+ other => panic!("expected BackendConflict, got {other:?}"),
558
+ }
559
+ // With allow_backend_switch=true (== --fresh), the same input succeeds.
560
+ let input_fresh = TransportFactoryInput::new(&workspace, TransportPurpose::LifecycleWorker)
561
+ .with_team_key(Some("team-a"))
562
+ .with_state(Some(&state))
563
+ .with_explicit_backend(Some(RequestedTransportBackend::Tmux))
564
+ .allow_backend_switch(true);
565
+ let ok = resolve_transport(input_fresh).expect("--fresh must allow switch");
566
+ assert_eq!(ok.kind, BackendKind::Tmux);
567
+ assert_eq!(ok.source, "cli");
568
+ }
569
+
570
+ // C-3 supplementary — state kind=conpty + legacy tmux endpoint emits
571
+ // a `legacy_tmux_endpoint_ignored` notice.
572
+ #[test]
573
+ fn state_conpty_with_legacy_tmux_endpoint_emits_ignored_notice() {
574
+ let workspace = ws();
575
+ let state = serde_json::json!({
576
+ "transport": { "kind": "conpty" },
577
+ "tmux_endpoint": "default",
578
+ });
579
+ let input = TransportFactoryInput::new(&workspace, TransportPurpose::LifecycleWorker)
580
+ .with_team_key(Some("team-a"))
581
+ .with_state(Some(&state));
582
+ let resolved = resolve_transport(input).expect("must succeed");
583
+ assert_eq!(resolved.kind, BackendKind::ConPty);
584
+ assert_eq!(resolved.source, "state.transport.kind");
585
+ // N38: one notice emitted.
586
+ assert_eq!(resolved.notices.len(), 1);
587
+ assert_eq!(resolved.notices[0].event, "transport.legacy_tmux_endpoint_ignored");
588
+ assert_eq!(
589
+ resolved.notices[0].payload["legacy_tmux_endpoint"],
590
+ serde_json::json!("default")
591
+ );
592
+ assert_eq!(
593
+ resolved.notices[0].payload["chosen_backend"],
594
+ serde_json::json!("conpty")
595
+ );
596
+ }
597
+
598
+ // C-3 also — pure conpty state (no legacy) emits zero notices.
599
+ #[test]
600
+ fn state_conpty_without_legacy_emits_no_notice() {
601
+ let workspace = ws();
602
+ let state = serde_json::json!({ "transport": { "kind": "conpty" } });
603
+ let input = TransportFactoryInput::new(&workspace, TransportPurpose::LifecycleWorker)
604
+ .with_team_key(Some("team-a"))
605
+ .with_state(Some(&state));
606
+ let resolved = resolve_transport(input).expect("must succeed");
607
+ assert_eq!(resolved.kind, BackendKind::ConPty);
608
+ assert!(resolved.notices.is_empty());
609
+ }
610
+
611
+ // Wire-string invariants for RequestedTransportBackend so a rename
612
+ // in Rust cannot silently drift the CLI accept-list.
613
+ #[test]
614
+ fn requested_backend_wire_strings_are_pinned() {
615
+ assert_eq!(RequestedTransportBackend::Tmux.as_wire(), "tmux");
616
+ assert_eq!(RequestedTransportBackend::ConPty.as_wire(), "conpty");
617
+ assert_eq!(
618
+ RequestedTransportBackend::parse_literal("tmux"),
619
+ Some(RequestedTransportBackend::Tmux)
620
+ );
621
+ assert_eq!(
622
+ RequestedTransportBackend::parse_literal("conpty"),
623
+ Some(RequestedTransportBackend::ConPty)
624
+ );
625
+ // pty is deliberately NOT parsed as ConPty at the wire level
626
+ // either. The spec.runtime.backend layer handles the typed
627
+ // refusal for the spec case; here we just prove the CLI
628
+ // literal parser rejects it.
629
+ assert_eq!(RequestedTransportBackend::parse_literal("pty"), None);
630
+ assert_eq!(RequestedTransportBackend::parse_literal("nonsense"), None);
631
+ }
632
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.5.0",
24
- "@team-agent/cli-darwin-x64": "0.5.0",
25
- "@team-agent/cli-linux-x64": "0.5.0"
23
+ "@team-agent/cli-darwin-arm64": "0.5.2",
24
+ "@team-agent/cli-darwin-x64": "0.5.2",
25
+ "@team-agent/cli-linux-x64": "0.5.2"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",