@team-agent/installer 0.5.2 → 0.5.4

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 (51) hide show
  1. package/Cargo.lock +3 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +20 -0
  4. package/crates/team-agent/src/cli/diagnose.rs +191 -31
  5. package/crates/team-agent/src/cli/emit.rs +5 -0
  6. package/crates/team-agent/src/cli/mod.rs +213 -89
  7. package/crates/team-agent/src/cli/tests/named_address.rs +4 -0
  8. package/crates/team-agent/src/codex_app_server.rs +62 -1
  9. package/crates/team-agent/src/conpty/backend.rs +120 -8
  10. package/crates/team-agent/src/coordinator/backoff.rs +88 -2
  11. package/crates/team-agent/src/coordinator/conpty_shim.rs +730 -0
  12. package/crates/team-agent/src/coordinator/health.rs +97 -11
  13. package/crates/team-agent/src/coordinator/mod.rs +8 -0
  14. package/crates/team-agent/src/coordinator/tests/daemon.rs +2 -1
  15. package/crates/team-agent/src/coordinator/tests/tick_core.rs +6 -0
  16. package/crates/team-agent/src/coordinator/tick.rs +13 -0
  17. package/crates/team-agent/src/diagnose/orphans.rs +18 -7
  18. package/crates/team-agent/src/leader/provider_attribution.rs +19 -34
  19. package/crates/team-agent/src/leader/tests/lease_api.rs +7 -0
  20. package/crates/team-agent/src/lib.rs +14 -1
  21. package/crates/team-agent/src/lifecycle/launch.rs +89 -92
  22. package/crates/team-agent/src/lifecycle/lock.rs +40 -33
  23. package/crates/team-agent/src/lifecycle/restart/agent.rs +25 -19
  24. package/crates/team-agent/src/lifecycle/restart/common.rs +53 -2
  25. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +108 -6
  26. package/crates/team-agent/src/lifecycle/restart/selection.rs +47 -18
  27. package/crates/team-agent/src/lifecycle/restart.rs +16 -6
  28. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +7 -0
  29. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +287 -53
  30. package/crates/team-agent/src/lifecycle/tests/restart.rs +144 -5
  31. package/crates/team-agent/src/lifecycle/types.rs +1 -0
  32. package/crates/team-agent/src/mcp_server/wire.rs +6 -7
  33. package/crates/team-agent/src/messaging/tests/runtime.rs +5 -0
  34. package/crates/team-agent/src/packaging/tests.rs +41 -6
  35. package/crates/team-agent/src/packaging/types.rs +31 -3
  36. package/crates/team-agent/src/platform/argv.rs +324 -0
  37. package/crates/team-agent/src/platform/errors.rs +95 -0
  38. package/crates/team-agent/src/platform/file_lock.rs +418 -0
  39. package/crates/team-agent/src/platform/mod.rs +66 -0
  40. package/crates/team-agent/src/platform/process.rs +555 -0
  41. package/crates/team-agent/src/provider/adapter.rs +103 -41
  42. package/crates/team-agent/src/provider/session/capture.rs +328 -66
  43. package/crates/team-agent/src/provider/session/resume.rs +30 -28
  44. package/crates/team-agent/src/provider/session_scan/common.rs +117 -1
  45. package/crates/team-agent/src/provider/session_scan/copilot.rs +1 -0
  46. package/crates/team-agent/src/provider/session_scan.rs +1 -0
  47. package/crates/team-agent/src/state/persist.rs +222 -25
  48. package/crates/team-agent/src/state/projection.rs +59 -4
  49. package/crates/team-agent/src/tmux_backend.rs +63 -13
  50. package/crates/team-agent/src/transport_factory.rs +124 -5
  51. package/package.json +4 -4
@@ -210,14 +210,22 @@ pub mod lifecycle_port {
210
210
  /// `&*box`. Factory refusal falls back to the workspace tmux
211
211
  /// backend byte-equivalent to today so daemon liveness paths
212
212
  /// don't crash.
213
- pub fn shutdown(workspace: &Path, keep_logs: bool, team: Option<&str>) -> Result<Value, CliError> {
213
+ pub fn shutdown(
214
+ workspace: &Path,
215
+ keep_logs: bool,
216
+ team: Option<&str>,
217
+ ) -> Result<Value, CliError> {
214
218
  let run_ws = crate::model::paths::canonical_run_workspace(workspace)
215
219
  .map_err(|e| CliError::Runtime(e.to_string()))?;
216
220
  let state = shutdown_state_for_team(&run_ws, team)?;
217
221
  if let Some(endpoint) = legacy_worker_tmux_endpoint(&state) {
218
222
  let transport = shutdown_transport_for_endpoint(endpoint);
219
223
  return shutdown_with_transport_and_state(
220
- workspace, keep_logs, team, &transport, Some(state),
224
+ workspace,
225
+ keep_logs,
226
+ team,
227
+ &transport,
228
+ Some(state),
221
229
  );
222
230
  }
223
231
  let boxed: Box<dyn crate::transport::Transport> =
@@ -352,11 +360,7 @@ pub mod lifecycle_port {
352
360
  let anchor_sessions = anchor_sessions_from_state(state, &pane_targets, event_log);
353
361
  match sessions_to_kill(&sessions, &anchor_sessions) {
354
362
  KillDecision::KillServerExclusive => {
355
- if state
356
- .get("tmux_socket_source")
357
- .and_then(Value::as_str)
358
- == Some("leader_env")
359
- {
363
+ if state.get("tmux_socket_source").and_then(Value::as_str) == Some("leader_env") {
360
364
  let _ = event_log.write(
361
365
  "shutdown.kill_server_skipped_shared_socket",
362
366
  json!({
@@ -463,10 +467,7 @@ pub mod lifecycle_port {
463
467
  .cloned()
464
468
  .collect::<Vec<_>>();
465
469
  if let Some(anchored) = target_spared_for_anchor {
466
- if !spared
467
- .iter()
468
- .any(|s| s.as_str() == anchored.as_str())
469
- {
470
+ if !spared.iter().any(|s| s.as_str() == anchored.as_str()) {
470
471
  spared.push(anchored);
471
472
  }
472
473
  }
@@ -486,7 +487,10 @@ pub mod lifecycle_port {
486
487
  // (this function) computes its own `to_kill` from `target`. Any
487
488
  // leader-prefixed entry here is a topology violation introduced
488
489
  // somewhere upstream. Log loudly and skip the kill.
489
- if session.as_str().starts_with(crate::leader::LEADER_SESSION_PREFIX) {
490
+ if session
491
+ .as_str()
492
+ .starts_with(crate::leader::LEADER_SESSION_PREFIX)
493
+ {
490
494
  eprintln!(
491
495
  "team_agent::layout shutdown_invariant_violation kind=KillListContainsLeaderSession \
492
496
  session=`{}` action=skipping_kill (post-Step-9 will hard-fail)",
@@ -723,11 +727,7 @@ pub mod lifecycle_port {
723
727
  if crate::state::projection::state_is_managed_leader(&state)
724
728
  && session_has_leader_anchor
725
729
  {
726
- let _ = event_log_write_session_spared(
727
- &run_workspace,
728
- session,
729
- &leader_anchor_ids,
730
- );
730
+ let _ = event_log_write_session_spared(&run_workspace, session, &leader_anchor_ids);
731
731
  push_unique_session(&mut spared_sessions, session.clone());
732
732
  let worker_panes = collect_session_worker_panes(
733
733
  &state,
@@ -742,10 +742,8 @@ pub mod lifecycle_port {
742
742
  }
743
743
  }
744
744
  }
745
- let _ = crate::lifecycle::display::close_team_display_backends(
746
- &run_workspace,
747
- session,
748
- );
745
+ let _ =
746
+ crate::lifecycle::display::close_team_display_backends(&run_workspace, session);
749
747
  } else {
750
748
  push_unique_session(&mut killed_sessions, session.clone());
751
749
  if let Err(error) = transport.kill_session(session) {
@@ -753,10 +751,8 @@ pub mod lifecycle_port {
753
751
  kill_error = Some(error.to_string());
754
752
  }
755
753
  }
756
- let _ = crate::lifecycle::display::close_team_display_backends(
757
- &run_workspace,
758
- session,
759
- );
754
+ let _ =
755
+ crate::lifecycle::display::close_team_display_backends(&run_workspace, session);
760
756
  }
761
757
  }
762
758
  deadline.check("reap_workspace_residuals")?;
@@ -943,7 +939,17 @@ pub mod lifecycle_port {
943
939
  }),
944
940
  )
945
941
  .map_err(|e| CliError::Runtime(e.to_string()))?;
946
- Ok(json!({
942
+ // 0.5.x Windows portability Batch 6 Option A (leader
943
+ // constraint 3): if a ConPTY shim was recorded in
944
+ // `state.transport.shim.pid`, terminate it explicitly via
945
+ // `platform::process::terminate_pid`.
946
+ //
947
+ // Batch 7 refinement: the field is emitted into the shutdown
948
+ // JSON only on Windows so Unix golden fixtures stay
949
+ // byte-preserving. The Unix behavior is a no-op (no shim
950
+ // concept there); adding a placeholder key would just noise
951
+ // up the fixtures.
952
+ let response = json!({
947
953
  "ok": ok,
948
954
  "status": status,
949
955
  "phase": phase,
@@ -966,8 +972,61 @@ pub mod lifecycle_port {
966
972
  "coordinator": {
967
973
  "status": coordinator_status,
968
974
  "pid": coordinator_pid,
975
+ },
976
+ });
977
+ #[cfg(windows)]
978
+ let mut response = response;
979
+ #[cfg(windows)]
980
+ {
981
+ if let Some(obj) = response.as_object_mut() {
982
+ obj.insert(
983
+ "conpty_shim".to_string(),
984
+ shutdown_conpty_shim(&run_workspace),
985
+ );
969
986
  }
970
- }))
987
+ }
988
+ #[cfg(not(windows))]
989
+ {
990
+ let _ = &run_workspace;
991
+ }
992
+ Ok(response)
993
+ }
994
+
995
+ /// 0.5.x Windows portability Batch 6 Option A: locate and
996
+ /// terminate the ConPTY shim if `state.transport.shim.pid` is
997
+ /// recorded. Returns a JSON blob for the shutdown response so
998
+ /// operators can distinguish "no shim was ever launched" from
999
+ /// "shim terminated cleanly" from "shim already gone".
1000
+ ///
1001
+ /// Windows-only; caller (`shutdown_with_transport_and_state`)
1002
+ /// only invokes on Windows. Unix has no shim concept.
1003
+ #[cfg(windows)]
1004
+ fn shutdown_conpty_shim(workspace: &Path) -> serde_json::Value {
1005
+ let Some(pid) = crate::coordinator::conpty_shim::recorded_shim_pid(workspace) else {
1006
+ return json!({ "action": "no_shim_recorded" });
1007
+ };
1008
+ // Prefer graceful termination — Windows maps this to
1009
+ // `TerminationOutcome::ForceOnly` (Batch 3 anchor: no
1010
+ // SIGTERM equivalent for non-console child), which
1011
+ // surfaces the honest audit signal in the JSON.
1012
+ let outcome = crate::platform::process::terminate_pid(
1013
+ pid,
1014
+ crate::platform::process::SignalKind::TerminateForce,
1015
+ );
1016
+ match outcome {
1017
+ Ok(crate::platform::process::TerminationOutcome::Requested) => {
1018
+ json!({ "pid": pid, "action": "terminated" })
1019
+ }
1020
+ Ok(crate::platform::process::TerminationOutcome::AlreadyGone) => {
1021
+ json!({ "pid": pid, "action": "already_gone" })
1022
+ }
1023
+ Ok(crate::platform::process::TerminationOutcome::ForceOnly { reason }) => {
1024
+ json!({ "pid": pid, "action": "terminated_force_only", "reason": reason })
1025
+ }
1026
+ Err(e) => {
1027
+ json!({ "pid": pid, "action": "terminate_failed", "reason": e.to_string() })
1028
+ }
1029
+ }
971
1030
  }
972
1031
 
973
1032
  /// T5 (harvest §1 / A2): the bounded stop RETAINS the JoinHandle and reclaims the
@@ -1230,10 +1289,7 @@ pub mod lifecycle_port {
1230
1289
  // probes when the primary transport is ConPTY; the primary
1231
1290
  // transport check above already covered the honest question
1232
1291
  // ("does the shim still have this session?").
1233
- let primary_is_conpty = matches!(
1234
- transport.kind(),
1235
- crate::transport::BackendKind::ConPty
1236
- );
1292
+ let primary_is_conpty = matches!(transport.kind(), crate::transport::BackendKind::ConPty);
1237
1293
  if !primary_is_conpty {
1238
1294
  let workspace_transport = shutdown_workspace_transport(workspace);
1239
1295
  match crate::transport::Transport::has_session(&workspace_transport, session) {
@@ -1353,34 +1409,48 @@ pub mod lifecycle_port {
1353
1409
  return;
1354
1410
  }
1355
1411
  for pid in pids.iter().rev() {
1356
- send_process_signal(*pid, libc::SIGTERM);
1412
+ let _ = crate::platform::process::terminate_pid(
1413
+ *pid,
1414
+ crate::platform::process::SignalKind::TerminateGraceful,
1415
+ );
1357
1416
  }
1358
1417
  std::thread::sleep(std::time::Duration::from_millis(150));
1359
1418
  for pid in pids.iter().rev() {
1360
- send_process_signal(*pid, libc::SIGKILL);
1419
+ let _ = crate::platform::process::terminate_pid(
1420
+ *pid,
1421
+ crate::platform::process::SignalKind::TerminateForce,
1422
+ );
1361
1423
  }
1362
1424
  wait_for_processes_gone(&pids, std::time::Duration::from_secs(1));
1363
1425
  }
1364
1426
 
1365
1427
  fn reap_process_groups(pgids: &[u32], protected: &ShutdownProtection) {
1428
+ // 0.5.x Windows portability Batch 3: routes group termination
1429
+ // through `platform::process::terminate_group`. Unix keeps
1430
+ // `kill(-pgid, SIGTERM|SIGKILL)` semantics byte-for-byte;
1431
+ // Windows returns AlreadyGone (no pgid concept — Job Object
1432
+ // teardown is the shim-side concern per design §Route B).
1433
+ // The `pgid_t <= 1 || protected.contains_pgid(...)` filter
1434
+ // stays in place so pgid 0/1 (kernel/init) and the caller's
1435
+ // own group are never targeted.
1366
1436
  for pgid in pgids {
1367
- let Ok(pgid_t) = libc::pid_t::try_from(*pgid) else {
1368
- continue;
1369
- };
1370
- if pgid_t <= 1 || protected.contains_pgid(*pgid) {
1437
+ if *pgid <= 1 || protected.contains_pgid(*pgid) {
1371
1438
  continue;
1372
1439
  }
1373
- send_process_signal_group(pgid_t, libc::SIGTERM);
1440
+ let _ = crate::platform::process::terminate_group(
1441
+ *pgid,
1442
+ crate::platform::process::SignalKind::TerminateGraceful,
1443
+ );
1374
1444
  }
1375
1445
  std::thread::sleep(std::time::Duration::from_millis(150));
1376
1446
  for pgid in pgids {
1377
- let Ok(pgid_t) = libc::pid_t::try_from(*pgid) else {
1378
- continue;
1379
- };
1380
- if pgid_t <= 1 || protected.contains_pgid(*pgid) {
1447
+ if *pgid <= 1 || protected.contains_pgid(*pgid) {
1381
1448
  continue;
1382
1449
  }
1383
- send_process_signal_group(pgid_t, libc::SIGKILL);
1450
+ let _ = crate::platform::process::terminate_group(
1451
+ *pgid,
1452
+ crate::platform::process::SignalKind::TerminateForce,
1453
+ );
1384
1454
  }
1385
1455
  }
1386
1456
 
@@ -1705,7 +1775,10 @@ pub mod lifecycle_port {
1705
1775
  let mut protected = ShutdownProtection::default();
1706
1776
  let current = std::process::id();
1707
1777
  protected.pids.insert(current);
1708
- if let Ok(pgid) = u32::try_from(unsafe { libc::getpgrp() }) {
1778
+ // 0.5.x Windows portability Batch 3: use platform primitive.
1779
+ // Windows returns None (no pgid concept) and the branch is
1780
+ // skipped honestly.
1781
+ if let Some(pgid) = crate::platform::process::current_process_group() {
1709
1782
  protected.pgids.insert(pgid);
1710
1783
  }
1711
1784
  let mut cursor = current;
@@ -1840,56 +1913,33 @@ pub mod lifecycle_port {
1840
1913
  }
1841
1914
  }
1842
1915
 
1843
- fn send_process_signal(pid: u32, signal: libc::c_int) {
1844
- let Ok(pid_t) = libc::pid_t::try_from(pid) else {
1845
- return;
1846
- };
1847
- unsafe {
1848
- libc::kill(pid_t, signal);
1849
- }
1850
- }
1851
-
1852
- fn send_process_signal_group(pgid: libc::pid_t, signal: libc::c_int) {
1853
- unsafe {
1854
- libc::kill(-pgid, signal);
1855
- }
1856
- }
1916
+ // 0.5.x Windows portability Batch 3: the four inline helpers
1917
+ // (send_process_signal, send_process_signal_group,
1918
+ // reap_child_if_possible, process_is_live) have been replaced
1919
+ // by direct calls through `crate::platform::process::*` at the
1920
+ // shutdown callsites above. The private helpers are removed so
1921
+ // there's a single source of truth for signal/waitpid/liveness
1922
+ // semantics; Unix behavior is byte-preserving (SignalKind maps
1923
+ // 1:1 to SIGTERM/SIGKILL, ProcessLiveness::Live == the previous
1924
+ // `kill(pid, 0) == 0 || EPERM` branch).
1857
1925
 
1858
1926
  fn wait_for_processes_gone(pids: &[u32], timeout: std::time::Duration) {
1859
1927
  let start = std::time::Instant::now();
1860
1928
  loop {
1861
1929
  for pid in pids {
1862
- reap_child_if_possible(*pid);
1930
+ crate::platform::process::reap_child_if_possible(*pid);
1863
1931
  }
1864
- if !pids.iter().any(|pid| process_is_live(*pid)) || start.elapsed() >= timeout {
1932
+ if !pids
1933
+ .iter()
1934
+ .any(|pid| crate::platform::process::pid_is_alive(*pid))
1935
+ || start.elapsed() >= timeout
1936
+ {
1865
1937
  return;
1866
1938
  }
1867
1939
  std::thread::sleep(std::time::Duration::from_millis(25));
1868
1940
  }
1869
1941
  }
1870
1942
 
1871
- fn reap_child_if_possible(pid: u32) {
1872
- let Ok(pid_t) = libc::pid_t::try_from(pid) else {
1873
- return;
1874
- };
1875
- let mut status = 0;
1876
- unsafe {
1877
- libc::waitpid(pid_t, &mut status, libc::WNOHANG);
1878
- }
1879
- }
1880
-
1881
- fn process_is_live(pid: u32) -> bool {
1882
- let Ok(pid_t) = libc::pid_t::try_from(pid) else {
1883
- return false;
1884
- };
1885
- let rc = unsafe { libc::kill(pid_t, 0) };
1886
- if rc == 0 {
1887
- return true;
1888
- }
1889
- let err = std::io::Error::last_os_error();
1890
- err.raw_os_error() == Some(libc::EPERM)
1891
- }
1892
-
1893
1943
  fn process_pgids(
1894
1944
  pids: &[u32],
1895
1945
  protected: &ShutdownProtection,
@@ -1900,8 +1950,15 @@ pub mod lifecycle_port {
1900
1950
  .filter_map(|pid| table.iter().find(|process| process.pid == *pid))
1901
1951
  .filter_map(|process| process.pgid)
1902
1952
  .filter(|pgid| {
1903
- libc::pid_t::try_from(*pgid)
1904
- .map(|pgid_t| pgid_t > 1 && !protected.contains_pgid(*pgid))
1953
+ // 0.5.x Windows portability Batch 4: `libc::pid_t` was
1954
+ // used here as a signed-integer conversion gate to
1955
+ // reject values > INT_MAX. Replace with the equivalent
1956
+ // `i32::try_from` (pgid_t is `c_int` on every Unix we
1957
+ // support). Windows has no pgid concept so `pgids`
1958
+ // is empty in practice — the filter is dead code on
1959
+ // Windows but must still compile.
1960
+ i32::try_from(*pgid)
1961
+ .map(|pgid_int| pgid_int > 1 && !protected.contains_pgid(*pgid))
1905
1962
  .unwrap_or(false)
1906
1963
  })
1907
1964
  .collect::<Vec<_>>();
@@ -1927,7 +1984,10 @@ pub mod lifecycle_port {
1927
1984
  .map(|process| process.pid)
1928
1985
  .collect::<std::collections::BTreeSet<_>>();
1929
1986
  for pid in root_pids {
1930
- if !protected.contains_pid(*pid) && process_is_live(*pid) && seen.insert(*pid) {
1987
+ if !protected.contains_pid(*pid)
1988
+ && crate::platform::process::pid_is_alive(*pid)
1989
+ && seen.insert(*pid)
1990
+ {
1931
1991
  residuals.push(ProcessInfo {
1932
1992
  pid: *pid,
1933
1993
  ppid: 0,
@@ -2873,14 +2933,38 @@ pub mod lifecycle_port {
2873
2933
  );
2874
2934
  }
2875
2935
  }
2936
+ if let crate::provider::session::ResumeRefusalReason::SessionIdentityMismatch {
2937
+ expected_agent_id,
2938
+ embedded_agent_id,
2939
+ session_id,
2940
+ rollout_path,
2941
+ } = reason
2942
+ {
2943
+ entry.insert(
2944
+ "expected_agent_id".to_string(),
2945
+ json!(expected_agent_id),
2946
+ );
2947
+ entry.insert(
2948
+ "embedded_agent_id".to_string(),
2949
+ json!(embedded_agent_id),
2950
+ );
2951
+ entry.insert(
2952
+ "poisoned_session_id".to_string(),
2953
+ json!(session_id),
2954
+ );
2955
+ if let Some(path) = rollout_path {
2956
+ entry.insert(
2957
+ "rollout_path".to_string(),
2958
+ json!(path.to_string_lossy()),
2959
+ );
2960
+ }
2961
+ }
2876
2962
  }
2877
2963
  Value::Object(entry)
2878
2964
  })
2879
2965
  .collect();
2880
- let unresumable_ids: Vec<&str> = unresumable
2881
- .iter()
2882
- .map(|w| w.agent_id.as_str())
2883
- .collect();
2966
+ let unresumable_ids: Vec<&str> =
2967
+ unresumable.iter().map(|w| w.agent_id.as_str()).collect();
2884
2968
  // Layer 2 self-healing (leader follow-up 2026-06-22): when
2885
2969
  // EVERY unresumable worker shares the same structured
2886
2970
  // refusal_reason wire string, refine the top-level `status`
@@ -2971,6 +3055,46 @@ pub mod lifecycle_port {
2971
3055
  unresumable_ids.join(", "),
2972
3056
  ),
2973
3057
  ),
3058
+ "session_identity_mismatch" => {
3059
+ let mut lines = Vec::new();
3060
+ for w in unresumable.iter() {
3061
+ if let Some(
3062
+ crate::provider::session::ResumeRefusalReason::SessionIdentityMismatch {
3063
+ expected_agent_id,
3064
+ embedded_agent_id,
3065
+ session_id,
3066
+ rollout_path,
3067
+ },
3068
+ ) = w.refusal_reason.as_ref()
3069
+ {
3070
+ let path = rollout_path
3071
+ .as_ref()
3072
+ .map(|p| p.to_string_lossy().into_owned())
3073
+ .unwrap_or_else(|| "<unknown>".to_string());
3074
+ lines.push(format!(
3075
+ " {}: session {} points to transcript identity {} at {} (expected {})",
3076
+ w.agent_id.as_str(),
3077
+ session_id,
3078
+ embedded_agent_id,
3079
+ path,
3080
+ expected_agent_id
3081
+ ));
3082
+ }
3083
+ }
3084
+ let mut msg = format!(
3085
+ "restart refused: provider session identity mismatch for {} worker(s) ({}). Pass --allow-fresh to discard the poisoned tuple and start fresh.",
3086
+ unresumable.len(),
3087
+ unresumable_ids.join(", "),
3088
+ );
3089
+ if !lines.is_empty() {
3090
+ msg.push_str("\nMismatched sessions:");
3091
+ for line in &lines {
3092
+ msg.push('\n');
3093
+ msg.push_str(line);
3094
+ }
3095
+ }
3096
+ ("refused_session_identity_mismatch".to_string(), msg)
3097
+ }
2974
3098
  _ => ("refused_resume_atomicity".to_string(), error.clone()),
2975
3099
  }
2976
3100
  } else {
@@ -355,6 +355,9 @@ fn resolve_leader_name_after_rebind() {
355
355
  let _ = std::fs::remove_dir_all(&ws);
356
356
  }
357
357
 
358
+ // 0.5.x Windows portability Batch 5: `FakeAppServer` uses Unix
359
+ // domain sockets — Unix-only.
360
+ #[cfg(unix)]
358
361
  #[test]
359
362
  fn resolve_app_server_leader_name_uses_typed_receiver_not_tmux_pane() {
360
363
  let ws = named_ws("appserver-leader-resolve");
@@ -419,6 +422,7 @@ fn resolve_app_server_leader_name_fails_closed_on_transport_conflict() {
419
422
  let _ = std::fs::remove_dir_all(&ws);
420
423
  }
421
424
 
425
+ #[cfg(unix)]
422
426
  #[test]
423
427
  fn send_to_name_app_server_leader_submits_turn_without_tmux_pane() {
424
428
  let ws = named_ws("appserver-leader-send");
@@ -1,10 +1,31 @@
1
1
  //! Minimal Codex app-server Unix-socket WebSocket client for leader delivery.
2
+ //!
3
+ //! 0.5.x Windows portability Batch 1: this client is fundamentally
4
+ //! Unix-only (`UnixStream` WebSocket handshake, uid/mode socket
5
+ //! ownership check). On Windows we still expose the same public API
6
+ //! surface so `messaging/delivery.rs`, `cli/send.rs`, `cli/named_address.rs`,
7
+ //! `leader/lease.rs` compile identically. Windows implementations of
8
+ //! `attach_probe` + `submit_to_bound_thread` return
9
+ //! `AppServerError::SocketUnreachable("codex_app_server unix socket not supported on this platform (Windows)")`
10
+ //! (N38 three-line typed unsupported: code+reason+action are baked into
11
+ //! `AppServerError::code()` and the `Display` impl). Truth source:
12
+ //! `.team/artifacts/0.5.x-windows-portability-survey-design.md` §Batch 1.
13
+ //!
14
+ //! Portable pieces below (types, JSON helpers) stay unconditional so
15
+ //! `receiver_is_app_server` / `binding_from_receiver` work identically
16
+ //! everywhere.
2
17
 
3
18
  use serde_json::{json, Value};
19
+ #[cfg(unix)]
4
20
  use std::io::{Read, Write};
21
+ #[cfg(unix)]
5
22
  use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
23
+ #[cfg(unix)]
6
24
  use std::os::unix::net::UnixStream;
7
- use std::path::{Path, PathBuf};
25
+ #[cfg(unix)]
26
+ use std::path::Path;
27
+ use std::path::PathBuf;
28
+ #[cfg(unix)]
8
29
  use std::time::Duration;
9
30
 
10
31
  pub const APP_SERVER_PROTOCOL_FIXTURE_CLI_MIN: &str = "0.139.0";
@@ -79,6 +100,7 @@ impl std::fmt::Display for AppServerError {
79
100
 
80
101
  impl std::error::Error for AppServerError {}
81
102
 
103
+ #[cfg(unix)]
82
104
  pub fn attach_probe(endpoint: &str, thread_id: &str) -> Result<AppServerBinding, AppServerError> {
83
105
  check_socket_ownership(endpoint)?;
84
106
  let socket = socket_path(endpoint)?;
@@ -95,6 +117,21 @@ pub fn attach_probe(endpoint: &str, thread_id: &str) -> Result<AppServerBinding,
95
117
  })
96
118
  }
97
119
 
120
+ /// Batch 1 Windows N38 typed-unsupported stub. Callers see the same
121
+ /// `AppServerError` shape as Unix — no silent success, no panic.
122
+ #[cfg(not(unix))]
123
+ pub fn attach_probe(
124
+ _endpoint: &str,
125
+ _thread_id: &str,
126
+ ) -> Result<AppServerBinding, AppServerError> {
127
+ Err(AppServerError::SocketUnreachable(
128
+ "codex_app_server unix-socket client not supported on this platform \
129
+ (Windows); use codex CLI stdio or ConPTY worker delivery instead"
130
+ .to_string(),
131
+ ))
132
+ }
133
+
134
+ #[cfg(unix)]
98
135
  pub fn submit_to_bound_thread(
99
136
  binding: &AppServerBinding,
100
137
  message_id: &str,
@@ -108,6 +145,19 @@ pub fn submit_to_bound_thread(
108
145
  client.turn_start(&binding.thread_id, message_id, rendered)
109
146
  }
110
147
 
148
+ #[cfg(not(unix))]
149
+ pub fn submit_to_bound_thread(
150
+ _binding: &AppServerBinding,
151
+ _message_id: &str,
152
+ _rendered: &str,
153
+ ) -> Result<AppServerSubmit, AppServerError> {
154
+ Err(AppServerError::SocketUnreachable(
155
+ "codex_app_server unix-socket submit not supported on this platform \
156
+ (Windows); use codex CLI stdio or ConPTY worker delivery instead"
157
+ .to_string(),
158
+ ))
159
+ }
160
+
111
161
  pub fn binding_from_receiver(receiver: &Value) -> Result<AppServerBinding, AppServerError> {
112
162
  let app = receiver
113
163
  .get("app_server")
@@ -135,6 +185,7 @@ fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, AppServerE
135
185
  .ok_or_else(|| AppServerError::ProtocolMismatch(format!("missing {field}")))
136
186
  }
137
187
 
188
+ #[cfg(unix)]
138
189
  fn validate_tuple(
139
190
  binding: &AppServerBinding,
140
191
  user_agent: &str,
@@ -163,6 +214,7 @@ fn validate_tuple(
163
214
  Ok(())
164
215
  }
165
216
 
217
+ #[cfg(unix)]
166
218
  fn check_socket_ownership(endpoint: &str) -> Result<(), AppServerError> {
167
219
  let path = socket_path(endpoint)?;
168
220
  let meta = std::fs::metadata(&path)
@@ -189,6 +241,7 @@ fn check_socket_ownership(endpoint: &str) -> Result<(), AppServerError> {
189
241
  Ok(())
190
242
  }
191
243
 
244
+ #[cfg(unix)]
192
245
  fn socket_path(endpoint: &str) -> Result<PathBuf, AppServerError> {
193
246
  let raw = endpoint.strip_prefix("unix://").unwrap_or(endpoint);
194
247
  if raw.is_empty() {
@@ -199,17 +252,20 @@ fn socket_path(endpoint: &str) -> Result<PathBuf, AppServerError> {
199
252
  Ok(PathBuf::from(raw))
200
253
  }
201
254
 
255
+ #[cfg(unix)]
202
256
  struct ThreadResume {
203
257
  thread_id: String,
204
258
  session_id: String,
205
259
  cwd: String,
206
260
  }
207
261
 
262
+ #[cfg(unix)]
208
263
  struct AppServerClient {
209
264
  stream: UnixStream,
210
265
  next_id: u64,
211
266
  }
212
267
 
268
+ #[cfg(unix)]
213
269
  impl AppServerClient {
214
270
  fn connect(path: &Path) -> Result<Self, AppServerError> {
215
271
  let mut stream = UnixStream::connect(path)
@@ -386,17 +442,20 @@ impl AppServerClient {
386
442
  }
387
443
  }
388
444
 
445
+ #[cfg(unix)]
389
446
  fn is_approval_method(method: &str) -> bool {
390
447
  method.contains("requestApproval")
391
448
  || method.contains("requestUserInput")
392
449
  || method.contains("elicitation/request")
393
450
  }
394
451
 
452
+ #[cfg(unix)]
395
453
  fn looks_busy(message: &str) -> bool {
396
454
  let lower = message.to_ascii_lowercase();
397
455
  lower.contains("active turn") || lower.contains("already has") || lower.contains("busy")
398
456
  }
399
457
 
458
+ #[cfg(unix)]
400
459
  fn read_http_response(stream: &mut UnixStream) -> Result<String, AppServerError> {
401
460
  let mut data = Vec::new();
402
461
  let mut buf = [0u8; 1];
@@ -414,6 +473,7 @@ fn read_http_response(stream: &mut UnixStream) -> Result<String, AppServerError>
414
473
  Ok(String::from_utf8_lossy(&data).to_string())
415
474
  }
416
475
 
476
+ #[cfg(unix)]
417
477
  fn write_ws_text(stream: &mut UnixStream, text: &str) -> Result<(), AppServerError> {
418
478
  let payload = text.as_bytes();
419
479
  let mut frame = vec![0x81];
@@ -436,6 +496,7 @@ fn write_ws_text(stream: &mut UnixStream, text: &str) -> Result<(), AppServerErr
436
496
  .map_err(|e| AppServerError::Io(e.to_string()))
437
497
  }
438
498
 
499
+ #[cfg(unix)]
439
500
  fn read_ws_text(stream: &mut UnixStream) -> Result<String, AppServerError> {
440
501
  loop {
441
502
  let mut header = [0u8; 2];