@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
@@ -53,6 +53,21 @@ pub fn coordinator_health(workspace: &WorkspacePath) -> HealthReport {
53
53
  /// `start_coordinator`(`lifecycle.py:49-121`):幂等 — 已健康 no-op(AlreadyRunning);metadata 不兼容
54
54
  /// 先 stop 再起;schema 不兼容拒启 + hint;否则 spawn `team-agent coordinator --workspace <ws>`。
55
55
  pub fn start_coordinator(workspace: &WorkspacePath) -> Result<StartReport, StartError> {
56
+ start_coordinator_with_team(workspace, None)
57
+ }
58
+
59
+ /// 0.5.x Windows portability Batch 9 F8 (leader msg_2a4cc1fa54c0):
60
+ /// forward `--team` to the spawned coord daemon so it doesn't have
61
+ /// to derive team_key from `state.active_team_key` at boot. The
62
+ /// derivation stays as fallback (see `coordinator::backoff::run_daemon`),
63
+ /// so Unix daemons and pre-existing test harnesses are byte-preserving.
64
+ ///
65
+ /// Callers that CAN pass team_key (Batch 9 quick-start Windows path)
66
+ /// SHOULD — that avoids Batch 8's F8 seed-state trap.
67
+ pub fn start_coordinator_with_team(
68
+ workspace: &WorkspacePath,
69
+ team_key: Option<&str>,
70
+ ) -> Result<StartReport, StartError> {
56
71
  let health = coordinator_health(workspace);
57
72
  if health.ok {
58
73
  return Ok(StartReport {
@@ -101,7 +116,13 @@ pub fn start_coordinator(workspace: &WorkspacePath) -> Result<StartReport, Start
101
116
  let mut command = Command::new(std::env::current_exe()?);
102
117
  command
103
118
  .args(["coordinator", "--workspace"])
104
- .arg(workspace.as_path())
119
+ .arg(workspace.as_path());
120
+ if let Some(tk) = team_key {
121
+ if !tk.is_empty() {
122
+ command.args(["--team", tk]);
123
+ }
124
+ }
125
+ command
105
126
  .stdin(Stdio::null())
106
127
  .stdout(Stdio::from(log))
107
128
  .stderr(Stdio::from(log_err));
@@ -135,7 +156,23 @@ fn detach_daemon_child(command: &mut Command) {
135
156
  }
136
157
  }
137
158
 
138
- #[cfg(not(unix))]
159
+ #[cfg(windows)]
160
+ fn detach_daemon_child(command: &mut Command) {
161
+ // 0.5.x Windows portability Batch 8 F7 (leader msg_590b4dce0f68):
162
+ // detach the coordinator daemon on Windows via
163
+ // `DETACHED_PROCESS | CREATE_BREAKAWAY_FROM_JOB` creation flags,
164
+ // matching what `coordinator::conpty_shim::spawn_shim_and_handshake`
165
+ // does for the shim. Without these flags, an SSH-launched
166
+ // quick-start blocks waiting for the coord daemon's process
167
+ // tree to exit (never happens — daemon runs forever), and the
168
+ // quick-start caller sees a hung command.
169
+ use std::os::windows::process::CommandExt;
170
+ const DETACHED_PROCESS: u32 = 0x00000008;
171
+ const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x01000000;
172
+ command.creation_flags(DETACHED_PROCESS | CREATE_BREAKAWAY_FROM_JOB);
173
+ }
174
+
175
+ #[cfg(not(any(unix, windows)))]
139
176
  fn detach_daemon_child(_command: &mut Command) {}
140
177
 
141
178
  /// `stop_coordinator`(`lifecycle.py:228-247`):SIGTERM pid + 清 pid/meta → typed report。
@@ -272,16 +309,32 @@ fn coordinator_command_matches_workspace(command: &str, workspaces: &[String]) -
272
309
  }
273
310
 
274
311
  fn terminate_pid(pid: Pid) -> bool {
312
+ // 0.5.x Windows portability Batch 3: routes signal delivery through
313
+ // `platform::process::terminate_pid`. Unix keeps
314
+ // SIGTERM → 5s grace → SIGKILL semantics byte-for-byte
315
+ // (`SignalKind::TerminateGraceful` → SIGTERM,
316
+ // `SignalKind::TerminateForce` → SIGKILL). Windows performs
317
+ // `TerminateProcess` for both kinds; the `TerminationOutcome::ForceOnly`
318
+ // return on the graceful call is what a future audit-event
319
+ // emitter (CR C-6) will trigger `platform.terminate_force_only`
320
+ // on. For this batch the return value is discarded, matching the
321
+ // current inline `let _ = send_signal(...)` pattern.
275
322
  if pid_is_running(pid).ok() == Some(false) {
276
323
  return true;
277
324
  }
278
325
  let pids = process_tree_pids(pid);
279
326
  for child in pids.iter().rev() {
280
- let _ = send_signal(*child, libc::SIGTERM);
327
+ let _ = crate::platform::process::terminate_pid(
328
+ child.get(),
329
+ crate::platform::process::SignalKind::TerminateGraceful,
330
+ );
281
331
  }
282
332
  if !wait_until_all_not_running(&pids, Duration::from_secs(5)) {
283
333
  for child in pids.iter().rev() {
284
- let _ = send_signal(*child, libc::SIGKILL);
334
+ let _ = crate::platform::process::terminate_pid(
335
+ child.get(),
336
+ crate::platform::process::SignalKind::TerminateForce,
337
+ );
285
338
  }
286
339
  }
287
340
  wait_until_all_not_running(&pids, Duration::from_secs(5))
@@ -348,16 +401,17 @@ fn wait_until_all_not_running(pids: &[Pid], timeout: Duration) -> bool {
348
401
  }
349
402
 
350
403
  fn reap_child_if_possible(pid: Pid) {
351
- let Ok(pid_t) = libc::pid_t::try_from(pid.get()) else {
352
- return;
353
- };
354
- let mut status = 0;
355
- unsafe {
356
- libc::waitpid(pid_t, &mut status, libc::WNOHANG);
357
- }
404
+ // Batch 3: routed through `platform::process`. Unix `waitpid
405
+ // (WNOHANG)`; Windows no-op (no zombie model).
406
+ crate::platform::process::reap_child_if_possible(pid.get());
358
407
  }
359
408
 
409
+ #[cfg(unix)]
410
+ #[allow(dead_code)]
360
411
  fn send_signal(pid: Pid, signal: libc::c_int) -> bool {
412
+ // Retained (dead code post-Batch-3) as a Unix-only helper for any
413
+ // future non-standard signal delivery. All product paths now use
414
+ // `crate::platform::process::terminate_pid` with `SignalKind`.
361
415
  let Ok(pid_t) = libc::pid_t::try_from(pid.get()) else {
362
416
  return false;
363
417
  };
@@ -383,6 +437,21 @@ fn wait_until_not_running(pid: Pid, timeout: Duration) -> bool {
383
437
 
384
438
  /// `pid_is_running`(`metadata.py:16-25`):`os.kill(pid, 0)` + `ps -o stat=` 查 zombie(Z* → 不算活)。
385
439
  /// §10 fallible:进程探测 I/O 可失败 → Result。
440
+ ///
441
+ /// 0.5.x Windows portability Batch 4: this function has UNIQUE
442
+ /// coordinator-metadata semantics — it treats `EPERM` as "not
443
+ /// running" (different from `platform::process::pid_liveness` which
444
+ /// treats `EPERM` as Live) because the coordinator only owns
445
+ /// processes it can signal, and uses `ps -o stat=` for zombie
446
+ /// detection. The `ps` shellout is Unix-only.
447
+ ///
448
+ /// On Windows the coordinator-metadata identity check runs through
449
+ /// `platform::process::pid_liveness` instead (Windows has no zombie
450
+ /// state — a process is either Live or Dead — so the additional
451
+ /// `ps stat=` step has no analogue). This preserves the coordinator's
452
+ /// "am I the owner" check without silently reporting stale pids as
453
+ /// alive.
454
+ #[cfg(unix)]
386
455
  pub fn pid_is_running(pid: Pid) -> Result<bool, std::io::Error> {
387
456
  let Ok(pid_t) = libc::pid_t::try_from(pid.get()) else {
388
457
  return Ok(false);
@@ -407,6 +476,23 @@ pub fn pid_is_running(pid: Pid) -> Result<bool, std::io::Error> {
407
476
  Ok(!stat.is_empty() && !stat.starts_with('Z'))
408
477
  }
409
478
 
479
+ /// Windows shim: no `ps stat=` zombie detection needed (Windows has
480
+ /// no zombie state — a process is either Live or Dead). Route through
481
+ /// `platform::process::pid_liveness` and map to bool. The EPERM
482
+ /// semantic ("we can't signal → treat as not running") maps to
483
+ /// Windows `ERROR_ACCESS_DENIED` which the platform layer already
484
+ /// treats as `Live`; so on Windows the coordinator sees a process
485
+ /// it can't query as still-running (safer than pretending it's gone
486
+ /// and losing the ownership handle).
487
+ #[cfg(not(unix))]
488
+ pub fn pid_is_running(pid: Pid) -> Result<bool, std::io::Error> {
489
+ match crate::platform::process::pid_liveness(pid.get())? {
490
+ crate::platform::process::ProcessLiveness::Live => Ok(true),
491
+ crate::platform::process::ProcessLiveness::Dead => Ok(false),
492
+ crate::platform::process::ProcessLiveness::Unknown { .. } => Ok(false),
493
+ }
494
+ }
495
+
410
496
  /// `read_coordinator_metadata`(`metadata.py:28-34`)。读 `coordinator.json`;损坏/缺失/非 dict → `None`。
411
497
  pub fn read_coordinator_metadata(workspace: &WorkspacePath) -> Option<CoordinatorMetadata> {
412
498
  let text = std::fs::read_to_string(coordinator_meta_path(workspace)).ok()?;
@@ -62,6 +62,14 @@ use crate::provider::{ProviderAdapter, TurnId, TurnState};
62
62
  use serde_json::Value;
63
63
 
64
64
  pub mod backoff;
65
+ // 0.5.x Windows portability Batch 6 Option A: shim lifecycle
66
+ // manager. Windows-only per design §Shim Lifecycle (the shim binary
67
+ // exists only on Windows; Unix uses tmux). Cfg-gated at the module
68
+ // level so callers can `crate::coordinator::conpty_shim::...`
69
+ // uniformly on Windows and get a compile error (rather than a
70
+ // silent no-op) on Unix — Unix code paths never reach for the shim.
71
+ #[cfg(windows)]
72
+ pub mod conpty_shim;
65
73
  pub mod health;
66
74
  pub mod orphan;
67
75
  pub mod runtime_detectors;
@@ -87,7 +87,7 @@ fn start_coordinator_fresh_workspace_decides_started() {
87
87
  fn run_daemon_once_writes_boot_metadata_and_returns_ok() {
88
88
  let (wp, dir) = daemon_ws();
89
89
  crate::state::persist::save_runtime_state(&dir, &serde_json::json!({"session_name": "team-x", "agents": {}})).unwrap();
90
- let r = run_daemon(DaemonArgs { workspace: wp.clone(), once: true, tick_interval_sec: None });
90
+ let r = run_daemon(DaemonArgs { workspace: wp.clone(), once: true, tick_interval_sec: None, team_key: None });
91
91
  assert!(r.is_ok(), "run_daemon --once must return Ok; got {r:?}");
92
92
  assert!(coordinator_meta_path(&wp).exists(), "run_daemon must write the coordinator boot metadata");
93
93
  }
@@ -438,6 +438,7 @@ fn run_daemon_backs_off_on_transient_tick_err_then_recovers_without_exiting() {
438
438
  workspace: WorkspacePath::new(dir.clone()),
439
439
  once: false,
440
440
  tick_interval_sec: Some(0.01), // tiny backoff so the test is fast
441
+ team_key: None,
441
442
  };
442
443
  let result = run_daemon_with_coordinator(&args, &coord);
443
444
  assert!(
@@ -192,6 +192,12 @@ fn p2_tick_skips_tmux_gate_when_session_name_absent() {
192
192
 
193
193
  // P1 — pid_is_running must use os.kill(pid,0) first: a pid owned by another user (pid 1 /
194
194
  // launchd, root) is EPERM → not signalable → False. Current only `ps -p` (rc=0) → True.
195
+ //
196
+ // 0.5.x Windows portability Batch 5: this test's PREMISE is Unix
197
+ // specific (pid 1 = init/launchd, EPERM semantics). On Windows,
198
+ // pid_is_running routes through `platform::process::pid_liveness` which
199
+ // has different accessible-pid semantics. Unix-only test.
200
+ #[cfg(unix)]
195
201
  #[test]
196
202
  fn p2_pid_is_running_false_for_cross_user_pid() {
197
203
  // The cross-user semantics only hold for a non-root caller (root CAN signal pid 1).
@@ -10,7 +10,8 @@ use crate::coordinator::health::{
10
10
  coordinator_metadata_ok, pid_is_running, read_coordinator_metadata, terminate_pid_tree,
11
11
  };
12
12
  use crate::coordinator::types::{OrphanReason, Pid, WorkspacePath};
13
- use crate::tmux_backend::TmuxBackend;
13
+ // Phase 1d Batch 6: `TmuxBackend` import removed — all sites now use
14
+ // `transport_factory::tmux_socket_name_transport` for grep-visibility.
14
15
  use crate::transport::{SessionName, Transport};
15
16
 
16
17
  #[derive(Debug, Clone)]
@@ -341,18 +342,33 @@ fn tmux_socket_names() -> Vec<String> {
341
342
  }
342
343
 
343
344
  fn tmux_socket_roots() -> Vec<PathBuf> {
344
- let uid = unsafe { libc::geteuid() };
345
- let mut roots = vec![PathBuf::from(format!("/tmp/tmux-{uid}"))];
346
- if let Some(tmpdir) = std::env::var_os("TMPDIR") {
347
- roots.push(PathBuf::from(tmpdir).join(format!("tmux-{uid}")));
345
+ // 0.5.x Windows portability Batch 1: tmux orphan scan is Unix-only
346
+ // (design §Layering Strategy "ConPTY orphan diagnostics should be
347
+ // a separate shim registry command later"). On Windows return
348
+ // empty so the caller loops zero times honestly.
349
+ #[cfg(unix)]
350
+ {
351
+ let uid = unsafe { libc::geteuid() };
352
+ let mut roots = vec![PathBuf::from(format!("/tmp/tmux-{uid}"))];
353
+ if let Some(tmpdir) = std::env::var_os("TMPDIR") {
354
+ roots.push(PathBuf::from(tmpdir).join(format!("tmux-{uid}")));
355
+ }
356
+ roots.sort();
357
+ roots.dedup();
358
+ roots
359
+ }
360
+ #[cfg(not(unix))]
361
+ {
362
+ Vec::new()
348
363
  }
349
- roots.sort();
350
- roots.dedup();
351
- roots
352
364
  }
353
365
 
354
366
  fn tmux_list_panes(socket: &str) -> Vec<TmuxPaneRow> {
355
- TmuxBackend::for_socket_name(socket)
367
+ // Phase 1d Batch 6: factory tmux-socket-name helper for
368
+ // grep-visibility. Tmux-only orphan scanner (enumerates tmux socket
369
+ // roots); ConPTY orphan diagnostics are a separate shim registry
370
+ // command, not routed here.
371
+ crate::transport_factory::tmux_socket_name_transport(socket)
356
372
  .list_targets()
357
373
  .unwrap_or_default()
358
374
  .into_iter()
@@ -664,7 +680,10 @@ fn kill_tmux_session(orphan: &OrphanRecord) -> bool {
664
680
  let (Some(socket), Some(session)) = (&orphan.tmux_socket, &orphan.session) else {
665
681
  return false;
666
682
  };
667
- TmuxBackend::for_socket_name(socket)
683
+ // Phase 1d Batch 6: factory tmux-socket-name helper. Explicit
684
+ // tmux-only kill; conpty shim orphans are NOT routed here (design
685
+ // §Batch 6).
686
+ crate::transport_factory::tmux_socket_name_transport(socket)
668
687
  .kill_session(&SessionName::new(session.clone()))
669
688
  .is_ok()
670
689
  }
@@ -134,6 +134,58 @@ pub fn attach_leader(
134
134
  })
135
135
  }
136
136
 
137
+ /// Explicit app-server leader binding. Validates the supplied socket/thread tuple
138
+ /// before writing the typed physical-channel anchor through the lease primitive.
139
+ pub fn attach_app_server_leader(
140
+ workspace: &Path,
141
+ team: Option<&str>,
142
+ socket: &str,
143
+ thread_id: &str,
144
+ ) -> Result<Value, LeaderError> {
145
+ let binding = crate::codex_app_server::attach_probe(socket, thread_id)
146
+ .map_err(|error| LeaderError::Validation(error.to_string()))?;
147
+ let event_log = crate::event_log::EventLog::new(workspace);
148
+ let scoped_team = team.filter(|value| !value.is_empty());
149
+ let mut state = if scoped_team.is_some() {
150
+ crate::state::projection::select_runtime_state(workspace, scoped_team)?
151
+ } else {
152
+ crate::state::persist::load_runtime_state(workspace)?
153
+ };
154
+ if !state.is_object() {
155
+ state = json!({});
156
+ }
157
+ if let Some(team) = scoped_team {
158
+ state["active_team_key"] = json!(team);
159
+ }
160
+ let team_key = canonical_owner_write_key(&state);
161
+ let next_epoch = OwnerEpoch(current_owner_epoch(&state).0.saturating_add(1));
162
+ let receiver = app_server_receiver_value(&binding, next_epoch);
163
+ let owner = app_server_owner_value(next_epoch);
164
+ let record = crate::state::ownership::OwnershipWrite::new()
165
+ .with_leader_receiver(receiver.clone())
166
+ .with_team_owner(owner.clone())
167
+ .with_owner_epoch(next_epoch.0);
168
+ crate::state::ownership::write_owner(&mut state, &team_key, record);
169
+ write_claim_state(workspace, &state, scoped_team, Some(&team_key))?;
170
+ event_log.write(
171
+ super::LeaderEvent::ReceiverAttached.name(),
172
+ json!({
173
+ "transport_kind": "codex_app_server",
174
+ "thread_id": binding.thread_id,
175
+ "owner_epoch": next_epoch.0,
176
+ "team": team_key,
177
+ }),
178
+ )?;
179
+ Ok(json!({
180
+ "ok": true,
181
+ "status": "claimed",
182
+ "team": team_key,
183
+ "owner_epoch": next_epoch.0,
184
+ "leader_receiver": receiver,
185
+ "team_owner": owner,
186
+ }))
187
+ }
188
+
137
189
  #[derive(Clone)]
138
190
  struct AttachLeaderTarget {
139
191
  info: PaneInfo,
@@ -156,8 +208,11 @@ fn attach_leader_targets(workspace: &Path, state: &Value) -> Vec<AttachLeaderTar
156
208
  }),
157
209
  );
158
210
  }
211
+ // Phase 1d Batch 6: factory tmux workspace helper for
212
+ // grep-visibility. Semantics unchanged; leader lease discovery is
213
+ // tmux-only (caller pane = tmux pane, MUST-12 anchor).
159
214
  targets.extend(
160
- crate::tmux_backend::TmuxBackend::for_workspace(workspace)
215
+ crate::transport_factory::tmux_workspace_transport(workspace)
161
216
  .list_targets()
162
217
  .unwrap_or_default()
163
218
  .into_iter()
@@ -167,10 +222,12 @@ fn attach_leader_targets(workspace: &Path, state: &Value) -> Vec<AttachLeaderTar
167
222
  }
168
223
 
169
224
  fn tmux_backend_for_endpoint(endpoint: &str) -> crate::tmux_backend::TmuxBackend {
225
+ // Phase 1d Batch 6: factory tmux channel helpers for grep-visibility.
226
+ // Same tmux-only semantics as before.
170
227
  if endpoint.is_empty() || endpoint == "default" {
171
- crate::tmux_backend::TmuxBackend::new()
228
+ crate::transport_factory::tmux_default_transport()
172
229
  } else {
173
- crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint)
230
+ crate::transport_factory::tmux_endpoint_transport(endpoint)
174
231
  }
175
232
  }
176
233
 
@@ -320,11 +377,13 @@ pub fn claim_leader(
320
377
  .unwrap_or_default();
321
378
  let raw_state = crate::state::persist::load_runtime_state(workspace)?;
322
379
  let event_log = crate::event_log::EventLog::new(workspace);
323
- let mut targets = crate::tmux_backend::TmuxBackend::for_workspace(workspace)
380
+ // Phase 1d Batch 6: factory tmux channel helpers for grep-visibility.
381
+ // Tmux-only leader-claim path (caller pane enumeration).
382
+ let mut targets = crate::transport_factory::tmux_workspace_transport(workspace)
324
383
  .list_targets()
325
384
  .unwrap_or_default();
326
385
  targets.extend(
327
- crate::tmux_backend::TmuxBackend::new()
386
+ crate::transport_factory::tmux_default_transport()
328
387
  .list_targets()
329
388
  .unwrap_or_default(),
330
389
  );
@@ -1055,7 +1114,7 @@ fn write_binding_to_state(
1055
1114
  }
1056
1115
  let team_key = canonical_owner_write_key(state);
1057
1116
  let record = crate::state::ownership::OwnershipWrite::new()
1058
- .with_leader_receiver(serde_json::to_value(receiver)?)
1117
+ .with_leader_receiver(tmux_receiver_value(receiver)?)
1059
1118
  .with_team_owner(serde_json::to_value(owner)?)
1060
1119
  .with_owner_epoch(owner.owner_epoch.0);
1061
1120
  crate::state::ownership::write_owner(state, &team_key, record);
@@ -1074,11 +1133,89 @@ fn write_receiver_to_state(
1074
1133
  }
1075
1134
  let team_key = canonical_owner_write_key(state);
1076
1135
  let record = crate::state::ownership::OwnershipWrite::new()
1077
- .with_leader_receiver(serde_json::to_value(receiver)?);
1136
+ .with_leader_receiver(tmux_receiver_value(receiver)?);
1078
1137
  crate::state::ownership::write_owner(state, &team_key, record);
1079
1138
  Ok(())
1080
1139
  }
1081
1140
 
1141
+ fn tmux_receiver_value(receiver: &LeaderReceiver) -> Result<Value, LeaderError> {
1142
+ Ok(write_leader_receiver_transport(
1143
+ serde_json::to_value(receiver)?,
1144
+ "direct_tmux",
1145
+ ))
1146
+ }
1147
+
1148
+ fn app_server_receiver_value(
1149
+ binding: &crate::codex_app_server::AppServerBinding,
1150
+ epoch: OwnerEpoch,
1151
+ ) -> Value {
1152
+ write_leader_receiver_transport(
1153
+ json!({
1154
+ "status": "attached",
1155
+ "provider": "codex",
1156
+ "owner_epoch": epoch.0,
1157
+ "app_server": {
1158
+ "socket": binding.socket,
1159
+ "thread_id": binding.thread_id,
1160
+ "session_id": binding.session_id,
1161
+ "cwd": binding.cwd,
1162
+ "cli_version": binding.cli_version,
1163
+ "bound_at": binding.bound_at,
1164
+ "source": "app-server"
1165
+ }
1166
+ }),
1167
+ "codex_app_server",
1168
+ )
1169
+ }
1170
+
1171
+ fn app_server_owner_value(epoch: OwnerEpoch) -> Value {
1172
+ json!({
1173
+ "provider": "codex",
1174
+ "transport_kind": "codex_app_server",
1175
+ "owner_epoch": epoch.0,
1176
+ "claimed_at": now_ts(),
1177
+ "claimed_via": "attach-app-server-leader",
1178
+ "os_user": std::env::var("USER")
1179
+ .or_else(|_| std::env::var("USERNAME"))
1180
+ .unwrap_or_default(),
1181
+ })
1182
+ }
1183
+
1184
+ fn write_leader_receiver_transport(mut receiver: Value, transport_kind: &str) -> Value {
1185
+ if let Some(obj) = receiver.as_object_mut() {
1186
+ obj.insert("mode".to_string(), json!(transport_kind));
1187
+ match transport_kind {
1188
+ "codex_app_server" => {
1189
+ obj.insert("transport_kind".to_string(), json!(transport_kind));
1190
+ for legacy in [
1191
+ "pane_id",
1192
+ "tmux_socket",
1193
+ "session_name",
1194
+ "window_index",
1195
+ "window_name",
1196
+ "pane_index",
1197
+ "pane_tty",
1198
+ "pane_current_command",
1199
+ "fingerprint",
1200
+ "leader_session_uuid",
1201
+ "attached_at",
1202
+ "discovery",
1203
+ "requested_provider",
1204
+ "warning",
1205
+ ] {
1206
+ obj.remove(legacy);
1207
+ }
1208
+ }
1209
+ "direct_tmux" => {
1210
+ obj.remove("app_server");
1211
+ obj.remove("transport_kind");
1212
+ }
1213
+ _ => {}
1214
+ }
1215
+ }
1216
+ receiver
1217
+ }
1218
+
1082
1219
  /// Stage 3 owner persist fix (architect direction 2026-06-24): determine
1083
1220
  /// the canonical team_key for an in-memory ownership write. When the state
1084
1221
  /// carries an `active_team_key` (set by `project_top_level_view` whenever
@@ -262,7 +262,9 @@ fn family_a_identity(
262
262
  }
263
263
 
264
264
  fn tmux_pane_current_command(workspace: &Path, pane: &str) -> Result<String, LeaderError> {
265
- TmuxBackend::for_workspace(workspace)
265
+ // Phase 1d Batch 6: factory tmux workspace helper for grep-visibility.
266
+ // Tmux-only owner-bind (caller pane = tmux pane).
267
+ crate::transport_factory::tmux_workspace_transport(workspace)
266
268
  .query(
267
269
  &Target::Pane(PaneId::new(pane)),
268
270
  PaneField::PaneCurrentCommand,
@@ -273,7 +275,8 @@ fn tmux_pane_current_command(workspace: &Path, pane: &str) -> Result<String, Lea
273
275
 
274
276
  fn tmux_pane_info(workspace: &Path, pane: &str) -> Option<PaneInfo> {
275
277
  let target = PaneId::new(pane);
276
- TmuxBackend::for_workspace(workspace)
278
+ // Phase 1d Batch 6: same as above.
279
+ crate::transport_factory::tmux_workspace_transport(workspace)
277
280
  .list_targets()
278
281
  .ok()?
279
282
  .into_iter()
@@ -98,44 +98,29 @@ fn provider_wire_for_command_attribution(provider: Provider) -> &'static str {
98
98
  }
99
99
  }
100
100
 
101
- #[cfg(target_os = "linux")]
101
+ // 0.5.x Windows portability Batch 4: process command-line + environ
102
+ // probes route through `crate::platform::argv`. Unix keeps
103
+ // byte-preserving semantics (Linux `/proc/<pid>/cmdline` + `environ`;
104
+ // other Unix `ps -p ... -o command=`; macOS environ is `None` because
105
+ // the pre-batch code also returned `None` on non-Linux). Windows
106
+ // returns `None` per design §Batch 4 conservative anchor (unknown
107
+ // argv must never infer elevated approval).
108
+ //
109
+ // The command-line format expected by the caller is a
110
+ // whitespace-joined single string, so we join the argv tokens after
111
+ // the platform probe. On the non-Linux Unix `ps` fallback path the
112
+ // tokens come already whitespace-split from `ps -o command=`; joining
113
+ // them back with spaces reproduces the exact pre-batch shape.
102
114
  fn process_command_line(pid: u32) -> Option<String> {
103
- let bytes = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
104
- let parts = bytes
105
- .split(|byte| *byte == 0)
106
- .filter(|part| !part.is_empty())
107
- .map(|part| String::from_utf8_lossy(part).to_string())
108
- .collect::<Vec<_>>();
109
- (!parts.is_empty()).then(|| parts.join(" "))
110
- }
111
-
112
- #[cfg(target_os = "linux")]
113
- fn process_environment(pid: u32) -> Option<String> {
114
- String::from_utf8(std::fs::read(format!("/proc/{pid}/environ")).ok()?).ok()
115
- }
116
-
117
- #[cfg(not(target_os = "linux"))]
118
- fn process_command_line(pid: u32) -> Option<String> {
119
- let output = crate::os_probe::bounded_command_output_with_probe(
120
- Command::new("ps")
121
- .arg("-p")
122
- .arg(pid.to_string())
123
- .args(["-o", "command="]),
124
- "provider_argv",
125
- Some(pid),
126
- )
127
- .ok()?;
128
- output
129
- .status
130
- .success()
131
- .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
132
- .filter(|text| !text.is_empty())
115
+ let argv = crate::platform::argv::argv_tokens(pid)?;
116
+ if argv.is_empty() {
117
+ return None;
118
+ }
119
+ Some(argv.join(" "))
133
120
  }
134
121
 
135
- #[cfg(not(target_os = "linux"))]
136
122
  fn process_environment(pid: u32) -> Option<String> {
137
- let _ = pid;
138
- None
123
+ crate::platform::argv::environ_text(pid)
139
124
  }
140
125
 
141
126
  #[cfg(test)]
@@ -434,7 +434,12 @@ fn execute_managed_leader_plan(
434
434
  let Some(window) = plan.leader_window.as_ref() else {
435
435
  return Err(LeaderError::Start("managed leader window missing".to_string()));
436
436
  };
437
- let transport = TmuxBackend::for_workspace(workspace);
437
+ // 0.5.x Phase 1d Batch 6: use the factory tmux channel helper
438
+ // (thin wrapper over `TmuxBackend::for_workspace`) for
439
+ // grep-visibility of every intentional tmux-only leader-launcher
440
+ // site. Semantics unchanged; managed leader hosting stays
441
+ // tmux-only per design §Batch 6.
442
+ let transport = crate::transport_factory::tmux_workspace_transport(workspace);
438
443
  let spawned = ensure_managed_leader_pane(&transport, session, window, plan, workspace)?;
439
444
  persist_managed_leader_binding(plan, workspace, &spawned)?;
440
445
  spawn_managed_provider_startup_prompt_handler(
@@ -1029,7 +1034,9 @@ fn spawn_managed_provider_startup_prompt_handler(
1029
1034
  pane_id: String,
1030
1035
  ) {
1031
1036
  std::thread::spawn(move || {
1032
- let transport = TmuxBackend::for_workspace(&workspace);
1037
+ // Phase 1d Batch 6: factory tmux workspace helper for
1038
+ // grep-visibility. Semantics unchanged.
1039
+ let transport = crate::transport_factory::tmux_workspace_transport(&workspace);
1033
1040
  let _ = handle_exec_provider_startup_prompts(
1034
1041
  provider,
1035
1042
  &workspace,
@@ -1042,9 +1049,13 @@ fn spawn_managed_provider_startup_prompt_handler(
1042
1049
  }
1043
1050
 
1044
1051
  fn tmux_transport_for_current_pane() -> TmuxBackend {
1052
+ // Phase 1d Batch 6: factory tmux channel helpers for
1053
+ // grep-visibility. Semantics unchanged; this is intentionally
1054
+ // tmux-only (caller pane = tmux `$TMUX` endpoint or default socket,
1055
+ // MUST-12 anchor).
1045
1056
  crate::tmux_backend::socket_name_from_tmux_env()
1046
- .map(|endpoint| TmuxBackend::for_tmux_endpoint(&endpoint))
1047
- .unwrap_or_else(TmuxBackend::new)
1057
+ .map(|endpoint| crate::transport_factory::tmux_endpoint_transport(&endpoint))
1058
+ .unwrap_or_else(crate::transport_factory::tmux_default_transport)
1048
1059
  }
1049
1060
 
1050
1061
  pub fn handle_exec_provider_startup_prompts(
@@ -1113,7 +1124,9 @@ fn provider_command_name(provider: Provider) -> &'static str {
1113
1124
  }
1114
1125
 
1115
1126
  fn tmux_session_exists(workspace: &Path, session: &SessionName) -> Result<bool, LeaderError> {
1116
- TmuxBackend::for_workspace(workspace)
1127
+ // Phase 1d Batch 6: factory tmux workspace helper for
1128
+ // grep-visibility. Tmux-only anchor (managed leader session lookup).
1129
+ crate::transport_factory::tmux_workspace_transport(workspace)
1117
1130
  .has_session(session)
1118
1131
  .map_err(|e| LeaderError::Start(format!("tmux has-session failed: {e}")))
1119
1132
  }