@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
@@ -112,6 +112,10 @@ fn dispatch(command: &str, args: &[String], cwd: &Path) -> Result<ExitCode, CliE
112
112
  "claim-leader" => cmd_claim_leader(&claim_leader_args(args, cwd)).map(emit_result),
113
113
  // Real dispatch: `cmd_attach_leader` writes the `leader_receiver` binding.
114
114
  "attach-leader" => cmd_attach_leader(&attach_leader_args(args, cwd)?).map(emit_result),
115
+ "attach-app-server-leader" => {
116
+ cmd_attach_app_server_leader(&attach_app_server_leader_args(args, cwd)?)
117
+ .map(emit_result)
118
+ }
115
119
  "identity" => cmd_identity(&identity_args(args, cwd)).map(emit_result),
116
120
  "approvals" => cmd_approvals(&approvals_args(args, cwd)).map(emit_result),
117
121
  "inbox" => cmd_inbox(&inbox_args(args, cwd)?).map(emit_result),
@@ -167,6 +171,7 @@ const DISPATCH_COMMANDS: &[&str] = &[
167
171
  "takeover",
168
172
  "claim-leader",
169
173
  "attach-leader",
174
+ "attach-app-server-leader",
170
175
  "identity",
171
176
  "approvals",
172
177
  "inbox",
@@ -210,6 +215,23 @@ fn is_known_subcommand(command: &str) -> bool {
210
215
  DISPATCH_COMMANDS.contains(&command) || SPEC_ONLY_HELP_COMMANDS.contains(&command)
211
216
  }
212
217
 
218
+ /// Test-only public accessor for `command_help` — allows integration
219
+ /// tests to grep the help copy without depending on internal parser
220
+ /// machinery.
221
+ pub fn __test_command_help(command: Option<&str>) -> String {
222
+ command_help(command)
223
+ }
224
+
225
+ /// Test-only public accessor for `quick_start_args` — allows
226
+ /// integration tests to exercise the parser without going through
227
+ /// stdio + the full `main` entrypoint.
228
+ pub fn __test_quick_start_args(
229
+ args: &[String],
230
+ cwd: &std::path::Path,
231
+ ) -> Result<crate::cli::types::QuickStartArgs, crate::cli::CliError> {
232
+ quick_start_args(args, cwd)
233
+ }
234
+
213
235
  fn command_help(command: Option<&str>) -> String {
214
236
  match command {
215
237
  None => {
@@ -222,7 +244,7 @@ fn command_help(command: Option<&str>) -> String {
222
244
  )
223
245
  }
224
246
  Some("init") => "usage: team-agent init [--workspace WORKSPACE] [--force] [--json]".to_string(),
225
- Some("quick-start") => "usage: team-agent quick-start [TEAMDIR] [--workspace WORKSPACE] [--name NAME] [--team-id TEAM|--team TEAM] [--yes] [--no-display] [--json]\n\ndefaults: display_backend=adaptive; set display_backend: none in TEAM.md or pass --no-display to use one worker window per agent.".to_string(),
247
+ Some("quick-start") => "usage: team-agent quick-start [TEAMDIR] [--workspace WORKSPACE] [--name NAME] [--team-id TEAM|--team TEAM] [--yes] [--no-display] [--backend tmux|conpty] [--json]\n\ndefaults: display_backend=adaptive; set display_backend: none in TEAM.md or pass --no-display to use one worker window per agent.\n\n--backend selects the worker transport (Phase 1d Batch 2): tmux (default on POSIX; unchanged behavior), conpty (Windows-native ConPTY worker transport; requires the shim binary and Windows host).".to_string(),
226
248
  Some("start") => "usage: team-agent start [TEAMDIR] [--yes] [--fresh] [--json]".to_string(),
227
249
  Some("compile") => "usage: team-agent compile --team TEAM [--out FILE] [--json]".to_string(),
228
250
  Some("send") => "usage: team-agent send TARGET MESSAGE... [--workspace WORKSPACE] [--team TEAM] [--targets AGENTS] [--to-name NAME] [--pane PANE] [--task TASK] [--sender SENDER] [--watch-result] [--requires-ack|--no-ack] [--no-wait] [--timeout SECONDS] [--confirm-human] [--message-id ID] [--json]\n\nMVP: name-based cross-workspace addressing assumes trusted local caller; no auth gate.".to_string(),
@@ -247,6 +269,7 @@ fn command_help(command: Option<&str>) -> String {
247
269
  Some("takeover") => "usage: team-agent takeover [--workspace WORKSPACE] [--team TEAM] [--confirm] [--json]".to_string(),
248
270
  Some("claim-leader") => "usage: team-agent claim-leader [--workspace WORKSPACE] [--team TEAM] [--confirm] [--json]".to_string(),
249
271
  Some("attach-leader") => "usage: team-agent attach-leader [--workspace WORKSPACE] [--team TEAM] [--pane PANE] [--provider PROVIDER] [--confirm] [--json]".to_string(),
272
+ Some("attach-app-server-leader") => "usage: team-agent attach-app-server-leader [--workspace WORKSPACE] [--team TEAM] --socket unix:///path.sock --thread-id THREAD_ID [--json]".to_string(),
250
273
  Some("identity") => "usage: team-agent identity [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
251
274
  Some("approvals") => "usage: team-agent approvals [AGENT] [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
252
275
  Some("inbox") => "usage: team-agent inbox AGENT [--workspace WORKSPACE] [--team TEAM] [--limit N] [--since CURSOR] [--json]".to_string(),
@@ -670,12 +693,18 @@ struct ParsedArgs {
670
693
  pane: Option<String>,
671
694
  to_name: Option<String>,
672
695
  provider: Option<String>,
696
+ socket: Option<String>,
697
+ thread_id: Option<String>,
673
698
  message_id: Option<String>,
674
699
  content: Option<String>,
675
700
  primary_error: Option<String>,
676
701
  agent_id: Option<String>,
677
702
  task_id: Option<String>,
678
703
  result_json: Option<String>,
704
+ /// 0.5.x Phase 1d Batch 2: quick-start `--backend <tmux|conpty>`.
705
+ /// Raw string (validated at the quick-start builder); the factory
706
+ /// enforces literal semantics.
707
+ backend: Option<String>,
679
708
  }
680
709
 
681
710
  fn parse_args(args: &[String]) -> ParsedArgs {
@@ -717,6 +746,7 @@ fn parse_args(args: &[String]) -> ParsedArgs {
717
746
  }
718
747
  "--force" => parsed.force = true,
719
748
  "--no-display" => parsed.no_display = true,
749
+ "--backend" => parsed.backend = next_arg(args, &mut i),
720
750
  "--discard-session" => parsed.discard_session = true,
721
751
  "--role-file" => parsed.role_file = next_arg(args, &mut i),
722
752
  "--as" => parsed.as_agent = next_arg(args, &mut i),
@@ -753,6 +783,8 @@ fn parse_args(args: &[String]) -> ParsedArgs {
753
783
  "--pane" => parsed.pane = next_arg(args, &mut i),
754
784
  "--to-name" => parsed.to_name = next_arg(args, &mut i),
755
785
  "--provider" => parsed.provider = next_arg(args, &mut i),
786
+ "--socket" => parsed.socket = next_arg(args, &mut i),
787
+ "--thread-id" => parsed.thread_id = next_arg(args, &mut i),
756
788
  "--message-id" => parsed.message_id = next_arg(args, &mut i),
757
789
  "--content" => parsed.content = next_arg(args, &mut i),
758
790
  "--primary-error" => parsed.primary_error = next_arg(args, &mut i),
@@ -770,6 +802,12 @@ fn parse_args(args: &[String]) -> ParsedArgs {
770
802
  other if other.starts_with("--provider=") => {
771
803
  parsed.provider = Some(other.trim_start_matches("--provider=").to_string());
772
804
  }
805
+ other if other.starts_with("--socket=") => {
806
+ parsed.socket = Some(other.trim_start_matches("--socket=").to_string());
807
+ }
808
+ other if other.starts_with("--thread-id=") => {
809
+ parsed.thread_id = Some(other.trim_start_matches("--thread-id=").to_string());
810
+ }
773
811
  other if other.starts_with('-') => {}
774
812
  other => parsed.positionals.push(other.to_string()),
775
813
  }
@@ -833,6 +871,20 @@ fn quick_start_args(args: &[String], cwd: &Path) -> Result<QuickStartArgs, CliEr
833
871
  } else {
834
872
  workspace.join(agents_dir)
835
873
  };
874
+ // 0.5.x Phase 1d Batch 2: validate the `--backend` literal up-front
875
+ // so users get a fast, actionable error instead of a downstream
876
+ // factory refusal. Accept the same literals as the factory
877
+ // `RequestedTransportBackend::parse_literal`.
878
+ if let Some(literal) = parsed.backend.as_deref() {
879
+ let normalized = literal.trim().to_ascii_lowercase();
880
+ if normalized != "tmux" && normalized != "conpty" {
881
+ return Err(CliError::Usage(format!(
882
+ "--backend must be `tmux` or `conpty`, got {literal:?}. \
883
+ `pty` is not a supported literal in Phase 1d (design \
884
+ §Non-Goals + CR C-1 ②)."
885
+ )));
886
+ }
887
+ }
836
888
  Ok(QuickStartArgs {
837
889
  workspace,
838
890
  agents_dir,
@@ -841,6 +893,7 @@ fn quick_start_args(args: &[String], cwd: &Path) -> Result<QuickStartArgs, CliEr
841
893
  yes: parsed.yes,
842
894
  no_display: parsed.no_display,
843
895
  json: parsed.json,
896
+ backend: parsed.backend,
844
897
  })
845
898
  }
846
899
 
@@ -1079,6 +1132,26 @@ fn attach_leader_args(args: &[String], cwd: &Path) -> Result<AttachLeaderArgs, C
1079
1132
  })
1080
1133
  }
1081
1134
 
1135
+ fn attach_app_server_leader_args(
1136
+ args: &[String],
1137
+ cwd: &Path,
1138
+ ) -> Result<AttachAppServerLeaderArgs, CliError> {
1139
+ let parsed = parse_args(args);
1140
+ Ok(AttachAppServerLeaderArgs {
1141
+ workspace: workspace(&parsed, cwd),
1142
+ team: parsed.team,
1143
+ socket: parsed
1144
+ .socket
1145
+ .filter(|socket| !socket.is_empty())
1146
+ .ok_or_else(|| CliError::Usage("missing --socket".to_string()))?,
1147
+ thread_id: parsed
1148
+ .thread_id
1149
+ .filter(|thread_id| !thread_id.is_empty())
1150
+ .ok_or_else(|| CliError::Usage("missing --thread-id".to_string()))?,
1151
+ json: parsed.json,
1152
+ })
1153
+ }
1154
+
1082
1155
  fn identity_args(args: &[String], cwd: &Path) -> IdentityArgs {
1083
1156
  let parsed = parse_args(args);
1084
1157
  IdentityArgs {
@@ -1444,10 +1517,15 @@ fn peek_args(args: &[String], cwd: &Path) -> Result<PeekArgs, CliError> {
1444
1517
  fn run_coordinator(args: &[String], cwd: &Path) -> Result<ExitCode, CliError> {
1445
1518
  let parsed = parse_args(args);
1446
1519
  let workspace = crate::coordinator::WorkspacePath::new(workspace(&parsed, cwd));
1520
+ // 0.5.x Windows portability Batch 9 F8: pass `--team` through
1521
+ // to the daemon so it doesn't have to derive from state at
1522
+ // boot time. `parse_args` already recognizes `--team`; we just
1523
+ // thread the value into `DaemonArgs::team_key`.
1447
1524
  crate::coordinator::run_daemon(crate::coordinator::DaemonArgs {
1448
1525
  workspace,
1449
1526
  once: parsed.once,
1450
1527
  tick_interval_sec: parsed.tick_interval,
1528
+ team_key: parsed.team.clone(),
1451
1529
  })
1452
1530
  .map(|()| ExitCode::Ok)
1453
1531
  .map_err(|e| CliError::Runtime(e.to_string()))
@@ -53,6 +53,7 @@ pub(crate) const SEND_REMINDER: &str = "Message delivered. Wait for the worker t
53
53
  pub(crate) const STATUS_REMINDER: &str = "To wait for results use --watch-result or team-agent collect. Do not capture-pane worker terminals.";
54
54
 
55
55
  pub mod adapters;
56
+ pub mod attach_app_server_leader;
56
57
  pub mod diagnose;
57
58
  pub mod emit;
58
59
  pub mod helpers;
@@ -64,6 +65,7 @@ pub mod status;
64
65
  pub mod types;
65
66
 
66
67
  pub use adapters::*;
68
+ pub use attach_app_server_leader::*;
67
69
  pub use diagnose::*;
68
70
  pub use emit::*;
69
71
  pub use leader::*;
@@ -120,14 +122,16 @@ pub mod lifecycle_port {
120
122
  team_id: Option<&str>,
121
123
  yes: bool,
122
124
  open_display: bool,
125
+ backend: Option<&str>,
123
126
  ) -> Result<Value, CliError> {
124
- match crate::lifecycle::quick_start_in_workspace_with_display(
127
+ match crate::lifecycle::quick_start_in_workspace_with_display_and_backend(
125
128
  workspace,
126
129
  agents_dir,
127
130
  name,
128
131
  yes,
129
132
  team_id,
130
133
  open_display,
134
+ backend,
131
135
  ) {
132
136
  Ok(report) => Ok(quick_start_value(report)),
133
137
  Err(e) => Ok(error_value(e)),
@@ -192,16 +196,40 @@ pub mod lifecycle_port {
192
196
  crate::tmux_backend::attach_command_for_workspace(cwd, session, window.as_str())
193
197
  }
194
198
  /// `runtime.shutdown`(`cmd_shutdown`)。
199
+ ///
200
+ /// 0.5.x Phase 1d Batch 5: the workspace-transport branch now routes
201
+ /// through `transport_factory::resolve_read_only_transport` so a
202
+ /// conpty team's shutdown reaches the shim rather than a
203
+ /// no-op tmux backend. Legacy tmux endpoint literal in state still
204
+ /// takes the `shutdown_transport_for_endpoint` tmux channel helper
205
+ /// path (intentional — this is the "attached explicit tmux
206
+ /// endpoint" branch and stays tmux-typed).
207
+ ///
208
+ /// `shutdown_with_transport_and_state` is generic over `&dyn
209
+ /// Transport`, so we hand the boxed factory backend down as
210
+ /// `&*box`. Factory refusal falls back to the workspace tmux
211
+ /// backend byte-equivalent to today so daemon liveness paths
212
+ /// don't crash.
195
213
  pub fn shutdown(workspace: &Path, keep_logs: bool, team: Option<&str>) -> Result<Value, CliError> {
196
214
  let run_ws = crate::model::paths::canonical_run_workspace(workspace)
197
215
  .map_err(|e| CliError::Runtime(e.to_string()))?;
198
216
  let state = shutdown_state_for_team(&run_ws, team)?;
199
- let transport = if let Some(endpoint) = legacy_worker_tmux_endpoint(&state) {
200
- shutdown_transport_for_endpoint(endpoint)
201
- } else {
202
- shutdown_workspace_transport(&run_ws)
203
- };
204
- shutdown_with_transport_and_state(workspace, keep_logs, team, &transport, Some(state))
217
+ if let Some(endpoint) = legacy_worker_tmux_endpoint(&state) {
218
+ let transport = shutdown_transport_for_endpoint(endpoint);
219
+ return shutdown_with_transport_and_state(
220
+ workspace, keep_logs, team, &transport, Some(state),
221
+ );
222
+ }
223
+ let boxed: Box<dyn crate::transport::Transport> =
224
+ match crate::transport_factory::resolve_read_only_transport(
225
+ &run_ws,
226
+ Some(&state),
227
+ crate::transport_factory::TransportPurpose::Shutdown,
228
+ ) {
229
+ Ok(r) => r.backend,
230
+ Err(_) => Box::new(shutdown_workspace_transport(&run_ws)),
231
+ };
232
+ shutdown_with_transport_and_state(workspace, keep_logs, team, boxed.as_ref(), Some(state))
205
233
  }
206
234
 
207
235
  /// E12 ①:从 state 锚 pane_id(leader_receiver/team_owner,top+teams)映射到其所在 session
@@ -915,7 +943,17 @@ pub mod lifecycle_port {
915
943
  }),
916
944
  )
917
945
  .map_err(|e| CliError::Runtime(e.to_string()))?;
918
- Ok(json!({
946
+ // 0.5.x Windows portability Batch 6 Option A (leader
947
+ // constraint 3): if a ConPTY shim was recorded in
948
+ // `state.transport.shim.pid`, terminate it explicitly via
949
+ // `platform::process::terminate_pid`.
950
+ //
951
+ // Batch 7 refinement: the field is emitted into the shutdown
952
+ // JSON only on Windows so Unix golden fixtures stay
953
+ // byte-preserving. The Unix behavior is a no-op (no shim
954
+ // concept there); adding a placeholder key would just noise
955
+ // up the fixtures.
956
+ let mut response = json!({
919
957
  "ok": ok,
920
958
  "status": status,
921
959
  "phase": phase,
@@ -938,8 +976,59 @@ pub mod lifecycle_port {
938
976
  "coordinator": {
939
977
  "status": coordinator_status,
940
978
  "pid": coordinator_pid,
979
+ },
980
+ });
981
+ #[cfg(windows)]
982
+ {
983
+ if let Some(obj) = response.as_object_mut() {
984
+ obj.insert(
985
+ "conpty_shim".to_string(),
986
+ shutdown_conpty_shim(&run_workspace),
987
+ );
941
988
  }
942
- }))
989
+ }
990
+ #[cfg(not(windows))]
991
+ {
992
+ let _ = &run_workspace;
993
+ }
994
+ Ok(response)
995
+ }
996
+
997
+ /// 0.5.x Windows portability Batch 6 Option A: locate and
998
+ /// terminate the ConPTY shim if `state.transport.shim.pid` is
999
+ /// recorded. Returns a JSON blob for the shutdown response so
1000
+ /// operators can distinguish "no shim was ever launched" from
1001
+ /// "shim terminated cleanly" from "shim already gone".
1002
+ ///
1003
+ /// Windows-only; caller (`shutdown_with_transport_and_state`)
1004
+ /// only invokes on Windows. Unix has no shim concept.
1005
+ #[cfg(windows)]
1006
+ fn shutdown_conpty_shim(workspace: &Path) -> serde_json::Value {
1007
+ let Some(pid) = crate::coordinator::conpty_shim::recorded_shim_pid(workspace) else {
1008
+ return json!({ "action": "no_shim_recorded" });
1009
+ };
1010
+ // Prefer graceful termination — Windows maps this to
1011
+ // `TerminationOutcome::ForceOnly` (Batch 3 anchor: no
1012
+ // SIGTERM equivalent for non-console child), which
1013
+ // surfaces the honest audit signal in the JSON.
1014
+ let outcome = crate::platform::process::terminate_pid(
1015
+ pid,
1016
+ crate::platform::process::SignalKind::TerminateForce,
1017
+ );
1018
+ match outcome {
1019
+ Ok(crate::platform::process::TerminationOutcome::Requested) => {
1020
+ json!({ "pid": pid, "action": "terminated" })
1021
+ }
1022
+ Ok(crate::platform::process::TerminationOutcome::AlreadyGone) => {
1023
+ json!({ "pid": pid, "action": "already_gone" })
1024
+ }
1025
+ Ok(crate::platform::process::TerminationOutcome::ForceOnly { reason }) => {
1026
+ json!({ "pid": pid, "action": "terminated_force_only", "reason": reason })
1027
+ }
1028
+ Err(e) => {
1029
+ json!({ "pid": pid, "action": "terminate_failed", "reason": e.to_string() })
1030
+ }
1031
+ }
943
1032
  }
944
1033
 
945
1034
  /// T5 (harvest §1 / A2): the bounded stop RETAINS the JoinHandle and reclaims the
@@ -1191,24 +1280,41 @@ pub mod lifecycle_port {
1191
1280
  }
1192
1281
  }
1193
1282
  }
1194
- let workspace_transport = shutdown_workspace_transport(workspace);
1195
- match crate::transport::Transport::has_session(&workspace_transport, session) {
1196
- Ok(true) => residual = true,
1197
- Ok(false) => {}
1198
- Err(err) if tmux_absent_error(&err.to_string()) => {}
1199
- Err(err) => {
1200
- error.get_or_insert_with(|| err.to_string());
1201
- residual = true;
1283
+ // 0.5.x Phase 1d Batch 5 (design §Batch 5 point 4): a ConPTY
1284
+ // team's residual check MUST NOT read the workspace tmux socket
1285
+ // + shared default tmux socket as evidence for/against ConPTY
1286
+ // residual. Those probes are meaningless for a shim-owned pane
1287
+ // universe: they will always return `has_session=false` (no
1288
+ // tmux session exists) which would look like "no residual" but
1289
+ // gives zero honest information about whether the shim / child
1290
+ // panes have actually been reaped. Skip the tmux fallback
1291
+ // probes when the primary transport is ConPTY; the primary
1292
+ // transport check above already covered the honest question
1293
+ // ("does the shim still have this session?").
1294
+ let primary_is_conpty = matches!(
1295
+ transport.kind(),
1296
+ crate::transport::BackendKind::ConPty
1297
+ );
1298
+ if !primary_is_conpty {
1299
+ let workspace_transport = shutdown_workspace_transport(workspace);
1300
+ match crate::transport::Transport::has_session(&workspace_transport, session) {
1301
+ Ok(true) => residual = true,
1302
+ Ok(false) => {}
1303
+ Err(err) if tmux_absent_error(&err.to_string()) => {}
1304
+ Err(err) => {
1305
+ error.get_or_insert_with(|| err.to_string());
1306
+ residual = true;
1307
+ }
1202
1308
  }
1203
- }
1204
- let default_transport = crate::tmux_backend::TmuxBackend::new();
1205
- match crate::transport::Transport::has_session(&default_transport, session) {
1206
- Ok(true) => residual = true,
1207
- Ok(false) => {}
1208
- Err(err) if tmux_absent_error(&err.to_string()) => {}
1209
- Err(err) => {
1210
- error.get_or_insert_with(|| err.to_string());
1211
- residual = true;
1309
+ let default_transport = crate::tmux_backend::TmuxBackend::new();
1310
+ match crate::transport::Transport::has_session(&default_transport, session) {
1311
+ Ok(true) => residual = true,
1312
+ Ok(false) => {}
1313
+ Err(err) if tmux_absent_error(&err.to_string()) => {}
1314
+ Err(err) => {
1315
+ error.get_or_insert_with(|| err.to_string());
1316
+ residual = true;
1317
+ }
1212
1318
  }
1213
1319
  }
1214
1320
  let sessions = if residual {
@@ -1308,34 +1414,48 @@ pub mod lifecycle_port {
1308
1414
  return;
1309
1415
  }
1310
1416
  for pid in pids.iter().rev() {
1311
- send_process_signal(*pid, libc::SIGTERM);
1417
+ let _ = crate::platform::process::terminate_pid(
1418
+ *pid,
1419
+ crate::platform::process::SignalKind::TerminateGraceful,
1420
+ );
1312
1421
  }
1313
1422
  std::thread::sleep(std::time::Duration::from_millis(150));
1314
1423
  for pid in pids.iter().rev() {
1315
- send_process_signal(*pid, libc::SIGKILL);
1424
+ let _ = crate::platform::process::terminate_pid(
1425
+ *pid,
1426
+ crate::platform::process::SignalKind::TerminateForce,
1427
+ );
1316
1428
  }
1317
1429
  wait_for_processes_gone(&pids, std::time::Duration::from_secs(1));
1318
1430
  }
1319
1431
 
1320
1432
  fn reap_process_groups(pgids: &[u32], protected: &ShutdownProtection) {
1433
+ // 0.5.x Windows portability Batch 3: routes group termination
1434
+ // through `platform::process::terminate_group`. Unix keeps
1435
+ // `kill(-pgid, SIGTERM|SIGKILL)` semantics byte-for-byte;
1436
+ // Windows returns AlreadyGone (no pgid concept — Job Object
1437
+ // teardown is the shim-side concern per design §Route B).
1438
+ // The `pgid_t <= 1 || protected.contains_pgid(...)` filter
1439
+ // stays in place so pgid 0/1 (kernel/init) and the caller's
1440
+ // own group are never targeted.
1321
1441
  for pgid in pgids {
1322
- let Ok(pgid_t) = libc::pid_t::try_from(*pgid) else {
1323
- continue;
1324
- };
1325
- if pgid_t <= 1 || protected.contains_pgid(*pgid) {
1442
+ if *pgid <= 1 || protected.contains_pgid(*pgid) {
1326
1443
  continue;
1327
1444
  }
1328
- send_process_signal_group(pgid_t, libc::SIGTERM);
1445
+ let _ = crate::platform::process::terminate_group(
1446
+ *pgid,
1447
+ crate::platform::process::SignalKind::TerminateGraceful,
1448
+ );
1329
1449
  }
1330
1450
  std::thread::sleep(std::time::Duration::from_millis(150));
1331
1451
  for pgid in pgids {
1332
- let Ok(pgid_t) = libc::pid_t::try_from(*pgid) else {
1333
- continue;
1334
- };
1335
- if pgid_t <= 1 || protected.contains_pgid(*pgid) {
1452
+ if *pgid <= 1 || protected.contains_pgid(*pgid) {
1336
1453
  continue;
1337
1454
  }
1338
- send_process_signal_group(pgid_t, libc::SIGKILL);
1455
+ let _ = crate::platform::process::terminate_group(
1456
+ *pgid,
1457
+ crate::platform::process::SignalKind::TerminateForce,
1458
+ );
1339
1459
  }
1340
1460
  }
1341
1461
 
@@ -1660,7 +1780,10 @@ pub mod lifecycle_port {
1660
1780
  let mut protected = ShutdownProtection::default();
1661
1781
  let current = std::process::id();
1662
1782
  protected.pids.insert(current);
1663
- if let Ok(pgid) = u32::try_from(unsafe { libc::getpgrp() }) {
1783
+ // 0.5.x Windows portability Batch 3: use platform primitive.
1784
+ // Windows returns None (no pgid concept) and the branch is
1785
+ // skipped honestly.
1786
+ if let Some(pgid) = crate::platform::process::current_process_group() {
1664
1787
  protected.pgids.insert(pgid);
1665
1788
  }
1666
1789
  let mut cursor = current;
@@ -1795,56 +1918,33 @@ pub mod lifecycle_port {
1795
1918
  }
1796
1919
  }
1797
1920
 
1798
- fn send_process_signal(pid: u32, signal: libc::c_int) {
1799
- let Ok(pid_t) = libc::pid_t::try_from(pid) else {
1800
- return;
1801
- };
1802
- unsafe {
1803
- libc::kill(pid_t, signal);
1804
- }
1805
- }
1806
-
1807
- fn send_process_signal_group(pgid: libc::pid_t, signal: libc::c_int) {
1808
- unsafe {
1809
- libc::kill(-pgid, signal);
1810
- }
1811
- }
1921
+ // 0.5.x Windows portability Batch 3: the four inline helpers
1922
+ // (send_process_signal, send_process_signal_group,
1923
+ // reap_child_if_possible, process_is_live) have been replaced
1924
+ // by direct calls through `crate::platform::process::*` at the
1925
+ // shutdown callsites above. The private helpers are removed so
1926
+ // there's a single source of truth for signal/waitpid/liveness
1927
+ // semantics; Unix behavior is byte-preserving (SignalKind maps
1928
+ // 1:1 to SIGTERM/SIGKILL, ProcessLiveness::Live == the previous
1929
+ // `kill(pid, 0) == 0 || EPERM` branch).
1812
1930
 
1813
1931
  fn wait_for_processes_gone(pids: &[u32], timeout: std::time::Duration) {
1814
1932
  let start = std::time::Instant::now();
1815
1933
  loop {
1816
1934
  for pid in pids {
1817
- reap_child_if_possible(*pid);
1935
+ crate::platform::process::reap_child_if_possible(*pid);
1818
1936
  }
1819
- if !pids.iter().any(|pid| process_is_live(*pid)) || start.elapsed() >= timeout {
1937
+ if !pids
1938
+ .iter()
1939
+ .any(|pid| crate::platform::process::pid_is_alive(*pid))
1940
+ || start.elapsed() >= timeout
1941
+ {
1820
1942
  return;
1821
1943
  }
1822
1944
  std::thread::sleep(std::time::Duration::from_millis(25));
1823
1945
  }
1824
1946
  }
1825
1947
 
1826
- fn reap_child_if_possible(pid: u32) {
1827
- let Ok(pid_t) = libc::pid_t::try_from(pid) else {
1828
- return;
1829
- };
1830
- let mut status = 0;
1831
- unsafe {
1832
- libc::waitpid(pid_t, &mut status, libc::WNOHANG);
1833
- }
1834
- }
1835
-
1836
- fn process_is_live(pid: u32) -> bool {
1837
- let Ok(pid_t) = libc::pid_t::try_from(pid) else {
1838
- return false;
1839
- };
1840
- let rc = unsafe { libc::kill(pid_t, 0) };
1841
- if rc == 0 {
1842
- return true;
1843
- }
1844
- let err = std::io::Error::last_os_error();
1845
- err.raw_os_error() == Some(libc::EPERM)
1846
- }
1847
-
1848
1948
  fn process_pgids(
1849
1949
  pids: &[u32],
1850
1950
  protected: &ShutdownProtection,
@@ -1855,8 +1955,15 @@ pub mod lifecycle_port {
1855
1955
  .filter_map(|pid| table.iter().find(|process| process.pid == *pid))
1856
1956
  .filter_map(|process| process.pgid)
1857
1957
  .filter(|pgid| {
1858
- libc::pid_t::try_from(*pgid)
1859
- .map(|pgid_t| pgid_t > 1 && !protected.contains_pgid(*pgid))
1958
+ // 0.5.x Windows portability Batch 4: `libc::pid_t` was
1959
+ // used here as a signed-integer conversion gate to
1960
+ // reject values > INT_MAX. Replace with the equivalent
1961
+ // `i32::try_from` (pgid_t is `c_int` on every Unix we
1962
+ // support). Windows has no pgid concept so `pgids`
1963
+ // is empty in practice — the filter is dead code on
1964
+ // Windows but must still compile.
1965
+ i32::try_from(*pgid)
1966
+ .map(|pgid_int| pgid_int > 1 && !protected.contains_pgid(*pgid))
1860
1967
  .unwrap_or(false)
1861
1968
  })
1862
1969
  .collect::<Vec<_>>();
@@ -1882,7 +1989,10 @@ pub mod lifecycle_port {
1882
1989
  .map(|process| process.pid)
1883
1990
  .collect::<std::collections::BTreeSet<_>>();
1884
1991
  for pid in root_pids {
1885
- if !protected.contains_pid(*pid) && process_is_live(*pid) && seen.insert(*pid) {
1992
+ if !protected.contains_pid(*pid)
1993
+ && crate::platform::process::pid_is_alive(*pid)
1994
+ && seen.insert(*pid)
1995
+ {
1886
1996
  residuals.push(ProcessInfo {
1887
1997
  pid: *pid,
1888
1998
  ppid: 0,